Files
tasteby/frontend/src/lib/api.ts
joungmin 6ea82a5561 feat(admin): #304+#323 LLM 검증 UI + 공통 유틸 추출
#323 (LLM 검증 어드민 UI):
- api.ts: getVerifyPending / verifyAll / verifyOne / setRestaurantHidden 추가
- Restaurant 타입에 hidden / hidden_reason / verified_at 추가
- RestaurantsPanel 헤더에 "미검증 N건 + LLM 검증" 버튼 추가
- 테이블에 "검증" 컬럼 추가:
  - hidden=true → "숨김 (사유)" 버튼 (클릭 시 해제)
  - verified_at 있고 visible → "OK" 버튼 (클릭 시 숨김)
  - 미검증 → "미검증" 텍스트

#304 (어드민 공통 유틸):
- lib/admin-utils.ts 신규
  - getAdminToken(): localStorage 직접 접근 통일
  - authHeaders(): 표준 Bearer 헤더
  - consumeSseStream(): SSE 라인 파싱 헬퍼
- colSpan 6 → 7로 검증 컬럼 반영

후속 분리: #329 (admin/page.tsx 전체 분리 + localStorage/SSE 호출 11+곳 교체)

Refs: #304 #323 #322
2026-06-15 13:57:33 +09:00

610 lines
16 KiB
TypeScript

const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
const TOKEN_KEY = "tasteby_token";
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function removeToken() {
localStorage.removeItem(TOKEN_KEY);
}
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...((init?.headers as Record<string, string>) || {}),
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers,
});
if (!res.ok) throw new Error(`API ${res.status}: ${res.statusText}`);
return res.json();
}
export interface Restaurant {
id: string;
name: string;
address: string | null;
region: string | null;
latitude: number;
longitude: number;
cuisine_type: string | null;
price_range: string | null;
google_place_id: string | null;
tabling_url: string | null;
catchtable_url: string | null;
business_status: string | null;
rating: number | null;
rating_count: number | null;
phone: string | null;
website: string | null;
channels?: string[];
foods_mentioned?: string[];
// #322 LLM 검증
hidden?: boolean;
hidden_reason?: string | null;
verified_at?: string | null;
}
export interface VideoLink {
video_id: string;
title: string;
url: string;
published_at: string | null;
foods_mentioned: string[];
evaluation: Record<string, string>;
guests: string[];
channel_name: string | null;
channel_id: string | null;
}
export interface Channel {
id: string;
channel_id: string;
channel_name: string;
title_filter: string | null;
description: string | null;
tags: string | null;
sort_order: number | null;
video_count: number;
last_scanned_at: string | null;
}
export interface Video {
id: string;
video_id: string;
title: string;
url: string;
status: string;
published_at: string | null;
channel_name: string;
has_transcript: boolean;
has_llm: boolean;
restaurant_count: number;
matched_count: number;
}
export interface VideoRestaurant {
restaurant_id: string;
name: string;
address: string | null;
cuisine_type: string | null;
price_range: string | null;
region: string | null;
foods_mentioned: string[];
evaluation: Record<string, string>;
guests: string[];
google_place_id: string | null;
has_location: boolean;
}
export interface VideoDetail extends Video {
transcript: string | null;
restaurants: VideoRestaurant[];
}
export interface User {
id: string;
email: string | null;
nickname: string | null;
avatar_url: string | null;
is_admin?: boolean;
}
export interface Review {
id: string;
user_id: string;
rating: number;
review_text: string | null;
visited_at: string | null;
created_at: string;
user_nickname: string | null;
user_avatar_url: string | null;
}
export interface Memo {
id: string;
user_id: string;
restaurant_id: string;
rating: number | null;
memo_text: string | null;
visited_at: string | null;
created_at: string;
updated_at: string;
}
export interface DaemonConfig {
scan_enabled: boolean;
scan_interval_min: number;
process_enabled: boolean;
process_interval_min: number;
process_limit: number;
last_scan_at: string | null;
last_process_at: string | null;
updated_at: string | null;
}
export interface ReviewsResponse {
reviews: Review[];
avg_rating: number | null;
review_count: number;
}
export const api = {
getRestaurants(params?: {
cuisine?: string;
region?: string;
channel?: string;
limit?: number;
offset?: number;
}) {
const sp = new URLSearchParams();
if (params?.cuisine) sp.set("cuisine", params.cuisine);
if (params?.region) sp.set("region", params.region);
if (params?.channel) sp.set("channel", params.channel);
if (params?.limit) sp.set("limit", String(params.limit));
if (params?.offset) sp.set("offset", String(params.offset));
const qs = sp.toString();
return fetchApi<Restaurant[]>(`/api/restaurants${qs ? `?${qs}` : ""}`);
},
getRestaurant(id: string) {
return fetchApi<Restaurant>(`/api/restaurants/${id}`);
},
updateRestaurant(id: string, data: Partial<Restaurant>) {
return fetchApi<{ ok: boolean }>(`/api/restaurants/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
deleteRestaurant(id: string) {
return fetchApi<{ ok: boolean }>(`/api/restaurants/${id}`, {
method: "DELETE",
});
},
getRestaurantVideos(id: string) {
return fetchApi<VideoLink[]>(`/api/restaurants/${id}/videos`);
},
search(q: string, mode: "keyword" | "semantic" | "hybrid" = "keyword") {
return fetchApi<Restaurant[]>(
`/api/search?q=${encodeURIComponent(q)}&mode=${mode}`
);
},
getChannels() {
return fetchApi<Channel[]>("/api/channels");
},
loginWithGoogle(idToken: string) {
return fetchApi<{ access_token: string; user: User }>("/api/auth/google", {
method: "POST",
body: JSON.stringify({ id_token: idToken }),
});
},
getMe() {
return fetchApi<User>("/api/auth/me");
},
getFavoriteStatus(restaurantId: string) {
return fetchApi<{ favorited: boolean }>(`/api/restaurants/${restaurantId}/favorite`);
},
toggleFavorite(restaurantId: string) {
return fetchApi<{ favorited: boolean }>(`/api/restaurants/${restaurantId}/favorite`, {
method: "POST",
});
},
getMyFavorites() {
return fetchApi<Restaurant[]>("/api/users/me/favorites");
},
getReviews(restaurantId: string) {
return fetchApi<ReviewsResponse>(`/api/restaurants/${restaurantId}/reviews`);
},
createReview(
restaurantId: string,
data: { rating: number; review_text?: string; visited_at?: string }
) {
return fetchApi<Review>(`/api/restaurants/${restaurantId}/reviews`, {
method: "POST",
body: JSON.stringify(data),
});
},
updateReview(
reviewId: string,
data: { rating: number; review_text?: string; visited_at?: string }
) {
return fetchApi<Review>(`/api/reviews/${reviewId}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
deleteReview(reviewId: string) {
return fetchApi<void>(`/api/reviews/${reviewId}`, {
method: "DELETE",
});
},
getMyReviews() {
return fetchApi<(Review & { restaurant_id: string; restaurant_name: string | null })[]>(
"/api/users/me/reviews?limit=100"
);
},
// Memos
getMemo(restaurantId: string) {
return fetchApi<Memo>(`/api/restaurants/${restaurantId}/memo`);
},
upsertMemo(restaurantId: string, data: { rating?: number; memo_text?: string; visited_at?: string }) {
return fetchApi<Memo>(`/api/restaurants/${restaurantId}/memo`, {
method: "POST",
body: JSON.stringify(data),
});
},
deleteMemo(restaurantId: string) {
return fetchApi<void>(`/api/restaurants/${restaurantId}/memo`, {
method: "DELETE",
});
},
getMyMemos() {
return fetchApi<(Memo & { restaurant_name: string | null })[]>("/api/users/me/memos");
},
// Stats
recordVisit() {
return fetchApi<{ ok: boolean }>("/api/stats/visit", { method: "POST" });
},
getVisits() {
return fetchApi<{ today: number; total: number }>("/api/stats/visits");
},
// Admin - Users
getAdminUsers(params?: { limit?: number; offset?: number }) {
const sp = new URLSearchParams();
if (params?.limit) sp.set("limit", String(params.limit));
if (params?.offset) sp.set("offset", String(params.offset));
const qs = sp.toString();
return fetchApi<{
users: {
id: string;
email: string | null;
nickname: string | null;
avatar_url: string | null;
is_admin: boolean;
provider: string | null;
created_at: string | null;
favorite_count: number;
review_count: number;
memo_count: number;
}[];
total: number;
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
},
updateAdminUserAdmin(userId: string, admin: boolean) {
return fetchApi<{ success: boolean }>(`/api/admin/users/${userId}/admin`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ admin }),
});
},
getAdminUserFavorites(userId: string) {
return fetchApi<
{
id: string;
name: string;
address: string | null;
region: string | null;
cuisine_type: string | null;
rating: number | null;
business_status: string | null;
created_at: string | null;
}[]
>(`/api/admin/users/${userId}/favorites`);
},
getAdminUserReviews(userId: string) {
return fetchApi<
{
id: string;
restaurant_id: string;
rating: number;
review_text: string | null;
visited_at: string | null;
created_at: string | null;
restaurant_name: string | null;
}[]
>(`/api/admin/users/${userId}/reviews`);
},
getAdminUserMemos(userId: string) {
return fetchApi<
{
id: string;
restaurant_id: string;
rating: number | null;
memo_text: string | null;
visited_at: string | null;
created_at: string;
restaurant_name: string | null;
}[]
>(`/api/admin/users/${userId}/memos`);
},
// Admin
addChannel(channelId: string, channelName: string, titleFilter?: string) {
return fetchApi<{ id: string; channel_id: string }>("/api/channels", {
method: "POST",
body: JSON.stringify({
channel_id: channelId,
channel_name: channelName,
title_filter: titleFilter || null,
}),
});
},
searchTabling(restaurantId: string) {
return fetchApi<{ title: string; url: string }[]>(
`/api/restaurants/${restaurantId}/tabling-search`
);
},
setTablingUrl(restaurantId: string, tablingUrl: string) {
return fetchApi<{ ok: boolean }>(
`/api/restaurants/${restaurantId}/tabling-url`,
{ method: "PUT", body: JSON.stringify({ tabling_url: tablingUrl }) }
);
},
searchCatchtable(restaurantId: string) {
return fetchApi<{ title: string; url: string }[]>(
`/api/restaurants/${restaurantId}/catchtable-search`
);
},
setCatchtableUrl(restaurantId: string, catchtableUrl: string) {
return fetchApi<{ ok: boolean }>(
`/api/restaurants/${restaurantId}/catchtable-url`,
{ method: "PUT", body: JSON.stringify({ catchtable_url: catchtableUrl }) }
);
},
updateChannel(id: string, data: { description?: string; tags?: string; sort_order?: number }) {
return fetchApi<{ ok: boolean }>(`/api/channels/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
deleteChannel(channelId: string) {
return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, {
method: "DELETE",
});
},
scanChannel(channelId: string, full: boolean = false) {
return fetchApi<{ total_fetched: number; new_videos: number }>(
`/api/channels/${channelId}/scan${full ? "?full=true" : ""}`,
{ method: "POST" }
);
},
getVideos(params?: { status?: string; limit?: number; offset?: number }) {
const sp = new URLSearchParams();
if (params?.status) sp.set("status", params.status);
if (params?.limit) sp.set("limit", String(params.limit));
if (params?.offset) sp.set("offset", String(params.offset));
const qs = sp.toString();
return fetchApi<Video[]>(`/api/videos${qs ? `?${qs}` : ""}`);
},
getVideoDetail(videoDbId: string) {
return fetchApi<VideoDetail>(`/api/videos/${videoDbId}`);
},
skipVideo(videoDbId: string) {
return fetchApi<{ ok: boolean }>(`/api/videos/${videoDbId}/skip`, {
method: "POST",
});
},
deleteVideo(videoDbId: string) {
return fetchApi<{ ok: boolean }>(`/api/videos/${videoDbId}`, {
method: "DELETE",
});
},
getExtractPrompt() {
return fetchApi<{ prompt: string }>("/api/videos/extract/prompt");
},
extractRestaurants(videoDbId: string, customPrompt?: string) {
return fetchApi<{ ok: boolean; restaurants_extracted: number }>(
`/api/videos/${videoDbId}/extract`,
{
method: "POST",
body: JSON.stringify(customPrompt ? { prompt: customPrompt } : {}),
}
);
},
fetchTranscript(videoDbId: string, mode: "auto" | "manual" | "generated" = "auto") {
return fetchApi<{ ok: boolean; length: number; source: string }>(
`/api/videos/${videoDbId}/fetch-transcript?mode=${mode}`,
{ method: "POST" }
);
},
uploadTranscript(videoDbId: string, text: string, source: string = "browser") {
return fetchApi<{ ok: boolean; length: number; source: string }>(
`/api/videos/${videoDbId}/upload-transcript`,
{ method: "POST", body: JSON.stringify({ text, source }) }
);
},
triggerProcessing(limit: number = 5) {
return fetchApi<{ restaurants_extracted: number }>(
`/api/videos/process?limit=${limit}`,
{ method: "POST" }
);
},
getBulkExtractPending() {
return fetchApi<{ count: number; videos: { id: string; title: string }[] }>(
"/api/videos/bulk-extract/pending"
);
},
getBulkTranscriptPending() {
return fetchApi<{ count: number; videos: { id: string; title: string }[] }>(
"/api/videos/bulk-transcript/pending"
);
},
updateVideo(videoDbId: string, data: { title: string }) {
return fetchApi<{ ok: boolean }>(`/api/videos/${videoDbId}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
addManualRestaurant(
videoDbId: string,
data: {
name: string;
address?: string;
region?: string;
cuisine_type?: string;
price_range?: string;
foods_mentioned?: string[];
evaluation?: string;
guests?: string[];
}
) {
return fetchApi<{ ok: boolean; restaurant_id: string; link_id: string }>(
`/api/videos/${videoDbId}/restaurants/manual`,
{ method: "POST", body: JSON.stringify(data) }
);
},
deleteVideoRestaurant(videoDbId: string, restaurantId: string) {
return fetchApi<{ ok: boolean }>(
`/api/videos/${videoDbId}/restaurants/${restaurantId}`,
{ method: "DELETE" }
);
},
updateVideoRestaurant(
videoDbId: string,
restaurantId: string,
data: Partial<VideoRestaurant>
) {
return fetchApi<{ ok: boolean }>(
`/api/videos/${videoDbId}/restaurants/${restaurantId}`,
{ method: "PUT", body: JSON.stringify(data) }
);
},
// Daemon config
getDaemonConfig() {
return fetchApi<DaemonConfig>("/api/daemon/config");
},
updateDaemonConfig(data: Partial<DaemonConfig>) {
return fetchApi<{ ok: boolean }>("/api/daemon/config", {
method: "PUT",
body: JSON.stringify(data),
});
},
runDaemonScan() {
return fetchApi<{ ok: boolean; new_videos: number }>("/api/daemon/run/scan", {
method: "POST",
});
},
flushCache() {
return fetchApi<{ ok: boolean }>("/api/admin/cache-flush", {
method: "POST",
});
},
runDaemonProcess(limit: number = 10) {
return fetchApi<{ ok: boolean; restaurants_extracted: number }>(
`/api/daemon/run/process?limit=${limit}`,
{ method: "POST" }
);
},
// #322 — LLM 검증 어드민 API
getVerifyPending() {
return fetchApi<{ pending: number }>("/api/admin/restaurants/verify/pending");
},
verifyAll(batchSize: number = 10) {
return fetchApi<{ processed: number }>(
`/api/admin/restaurants/verify/all?batchSize=${batchSize}`,
{ method: "POST" }
);
},
verifyOne(id: string) {
return fetchApi<{ success: boolean; id: string }>(
`/api/admin/restaurants/${id}/verify`,
{ method: "POST" }
);
},
setRestaurantHidden(id: string, hidden: boolean, reason: string = "manual") {
return fetchApi<{ success: boolean; id: string; hidden: boolean }>(
`/api/admin/restaurants/${id}/hidden`,
{
method: "PATCH",
body: JSON.stringify({ hidden, reason }),
}
);
},
};