#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
610 lines
16 KiB
TypeScript
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 }),
|
|
}
|
|
);
|
|
},
|
|
};
|