diff --git a/packages/web/lib/hooks/useImages.ts b/packages/web/lib/hooks/useImages.ts index f2afaa28..8f98e3f4 100644 --- a/packages/web/lib/hooks/useImages.ts +++ b/packages/web/lib/hooks/useImages.ts @@ -8,7 +8,6 @@ import { useQuery, useInfiniteQuery, - useQueryClient, keepPreviousData, } from "@tanstack/react-query"; import { @@ -20,8 +19,6 @@ import { } from "@decoded/shared/supabase/queries/images"; import { getPost, listPosts } from "@/lib/api/generated/posts/posts"; import { postDetailToImageDetail } from "@/lib/api/adapters/postDetailToImageDetail"; -import { fetchPostWithSpotsAndSolutions } from "@/lib/supabase/queries/posts"; -import { spotToItemRow } from "@/lib/components/detail/types"; import type { CategoryFilter, ImagePage, @@ -208,97 +205,20 @@ export function useInfinitePosts(params: { queryFn: async ({ pageParam }) => { const page = (pageParam as number) ?? 1; - // hasMagazine=true → REST API 사용 (Supabase posts 뷰에 magazine 컬럼 없음) - if (hasMagazine) { - const response = await listPosts({ - page, - per_page: limit, - sort, - has_magazine: true, - artist_name: mediaName ?? artistName, - group_name: castName ? undefined : groupName, - context: - contextType ?? - (category && category !== "all" ? category : undefined), - }); - - const items: PostGridItem[] = response.data.map((post) => ({ - id: post.id, - imageUrl: post.image_url, - postId: post.id, - postSource: "post" as const, - postAccount: post.artist_name ?? post.group_name ?? "", - postCreatedAt: post.created_at, - spotCount: post.spot_count ?? 0, - viewCount: post.view_count, - title: post.post_magazine_title ?? post.title ?? null, - })); - - const totalPages = response.pagination.total_pages; - const hasMore = page < totalPages; - return { items, nextPage: hasMore ? page + 1 : null, hasMore }; - } - - // 일반 모드 → Supabase 직접 쿼리 - const from = (page - 1) * limit; - const to = from + limit - 1; - - // Query posts directly with same filters as the old explore_posts view - let query = supabaseBrowserClient - .from("posts") - .select("*, post_magazines!inner(title)", { count: "exact" }) - .eq("status", "active") - .not("image_url", "is", null) - .eq("created_with_solutions", true) - .eq("post_magazines.status", "published"); - - // category filter (flat) — skip if contextType is set (hierarchical takes precedence) - if (category && category !== "all" && !contextType) { - query = query.eq("context", category); - } - if (artistName) { - query = query.ilike("artist_name", `%${artistName}%`); - } - if (groupName) { - query = query.ilike("group_name", `%${groupName}%`); - } - - // mediaName from hierarchical filter — matches group_name column - if (mediaName) { - query = query.ilike("group_name", `%${mediaName}%`); - } - // castName from hierarchical filter — matches artist_name column - if (castName) { - query = query.ilike("artist_name", `%${castName}%`); - } - // contextType from hierarchical filter — matches context column exactly - if (contextType) { - query = query.eq("context", contextType); - } - - // Sort - if (sort === "popular") { - query = query.order("view_count", { ascending: false }); - } else if (sort === "trending") { - query = query.order("trending_score", { ascending: false }); - } else { - query = query.order("created_at", { ascending: false }); - } - - query = query.range(from, to); - - const { data, count, error } = await query; - - if (error) { - throw new Error(error.message); - } - - const totalItems = count ?? 0; - const totalPages = Math.ceil(totalItems / limit); - const hasMore = page < totalPages; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const items: PostGridItem[] = (data ?? []).map((post: any) => ({ + // REST API + const response = await listPosts({ + page, + per_page: limit, + sort, + has_magazine: true, + artist_name: mediaName ?? artistName, + group_name: castName ? undefined : groupName, + context: + contextType ?? + (category && category !== "all" ? category : undefined), + }); + + const items: PostGridItem[] = response.data.map((post) => ({ id: post.id, imageUrl: post.image_url, postId: post.id, @@ -310,6 +230,8 @@ export function useInfinitePosts(params: { title: post.post_magazine_title ?? post.title ?? null, })); + const totalPages = response.pagination.total_pages; + const hasMore = page < totalPages; return { items, nextPage: hasMore ? page + 1 : null, hasMore }; }, getNextPageParam: (lastPage) => lastPage.nextPage, @@ -325,129 +247,11 @@ export function useInfinitePosts(params: { * Tries REST API first (production), falls back to Supabase direct (dev without backend). */ export function usePostDetailForImage(postId: string) { - const queryClient = useQueryClient(); - return useQuery({ queryKey: ["posts", "detail", "image", postId], queryFn: async () => { - // Helper: eagerly prefetch magazine data once we have a magazine_id - const prefetchMagazine = (magazineId: string | null | undefined) => { - if (!magazineId) return; - queryClient.prefetchQuery({ - queryKey: ["post-magazines", magazineId], - queryFn: async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data, error } = await (supabaseBrowserClient as any) - .from("post_magazines") - .select("*") - .eq("id", magazineId) - .single(); - if (error || !data) return null; - return data as unknown as PostMagazineResponse; - }, - staleTime: 1000 * 60 * 5, - }); - }; - - // 1. Try REST API (works when backend is running) - try { - const response = await getPost(postId); - const detail = postDetailToImageDetail(response, postId); - // Start magazine fetch immediately (no waterfall) - prefetchMagazine((detail as any)?.post_magazine_id); - return detail; - } catch { - // Backend unavailable — fall through to Supabase - } - - // 2. Fallback: Supabase direct query - try { - const result = await fetchPostWithSpotsAndSolutions(postId); - if (!result) return null; - - const { post, spots, solutions } = result; - // DB has columns not in PostRow type (post_magazine_id, ai_summary, etc.) - const postAny = post as Record; - - // Eagerly prefetch magazine in Supabase fallback path too - prefetchMagazine(postAny.post_magazine_id as string | null); - - const items = spots.map((spot) => { - const topSolution = solutions.find((s) => s.spot_id === spot.id); - return spotToItemRow(spot, topSolution); - }); - - return { - id: post.id, - image_hash: "", - image_url: post.image_url, - status: (post.status ?? "pending") as - | "pending" - | "extracted" - | "skipped" - | "extracted_metadata", - with_items: items.length > 0, - created_at: post.created_at, - items, - posts: [ - { - id: post.id, - account: post.artist_name ?? post.group_name ?? "", - article: post.title ?? null, - created_at: post.created_at, - item_ids: null, - metadata: [], - ts: post.created_at, - } as any, - ], - postImages: [ - { - post: { - id: post.id, - account: post.artist_name ?? post.group_name ?? "", - article: post.title ?? null, - created_at: post.created_at, - } as any, - created_at: post.created_at, - item_locations: spots.map((s, idx) => ({ - item_id: idx + 1, - center: [ - Math.max( - 0, - Math.min( - 1, - parseFloat(s.position_left) > 1 - ? parseFloat(s.position_left) / 100 - : parseFloat(s.position_left) - ) - ), - Math.max( - 0, - Math.min( - 1, - parseFloat(s.position_top) > 1 - ? parseFloat(s.position_top) / 100 - : parseFloat(s.position_top) - ) - ), - ], - })), - item_locations_updated_at: post.updated_at, - } as any, - ], - // Extended fields (exist in DB but not in PostRow type) - post_owner_id: post.user_id ?? null, - post_magazine_id: (postAny.post_magazine_id as string) ?? null, - ai_summary: (postAny.ai_summary as string) ?? null, - artist_name: post.artist_name ?? null, - group_name: post.group_name ?? null, - created_with_solutions: - (postAny.created_with_solutions as boolean) ?? null, - like_count: (postAny.like_count as number) ?? 0, - } as ImageDetail; - } catch { - return null; - } + const response = await getPost(postId); + return postDetailToImageDetail(response, postId); }, enabled: !!postId, staleTime: 1000 * 60, diff --git a/packages/web/lib/hooks/usePosts.ts b/packages/web/lib/hooks/usePosts.ts index 3bb0d7d2..f2e4ac95 100644 --- a/packages/web/lib/hooks/usePosts.ts +++ b/packages/web/lib/hooks/usePosts.ts @@ -5,12 +5,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { - fetchPostWithSpotsAndSolutions, - fetchPostWithImagesAndItems, - type PostDetail, - type LegacyPostDetail, -} from "@/lib/supabase/queries/posts"; -import { + getPost, listPosts, updatePost as updatePostGenerated, deletePost as deletePostGenerated, @@ -20,7 +15,7 @@ import type { PostsListResponse, PostsListParams, } from "@/lib/api/mutation-types"; -import type { UpdatePostDto, PostResponse } from "@/lib/api/generated/models"; +import type { UpdatePostDto, PostDetailResponse } from "@/lib/api/generated/models"; // ============================================================ // Query Keys @@ -41,29 +36,15 @@ export const postKeys = { /** * React Query hook for fetching a single post with its spots and solutions + * Uses backend REST API instead of direct Supabase queries * * @param id - Post ID to fetch * @returns React Query result with data, loading, error states */ export function usePostById(id: string) { - return useQuery({ + return useQuery({ queryKey: postKeys.detail(id), - queryFn: () => fetchPostWithSpotsAndSolutions(id), - enabled: !!id, - }); -} - -/** - * React Query hook for fetching a single post in legacy format - * @deprecated Use usePostById instead - * - * @param id - Post ID to fetch - * @returns React Query result with legacy data format - */ -export function usePostByIdLegacy(id: string) { - return useQuery({ - queryKey: ["posts", "detail", "legacy", id], - queryFn: () => fetchPostWithImagesAndItems(id), + queryFn: () => getPost(id), enabled: !!id, }); } @@ -193,6 +174,5 @@ export type { Post, PostsListResponse, PostsListParams, - PostDetail, - LegacyPostDetail, + PostDetailResponse, }; diff --git a/packages/web/lib/hooks/useSolutions.ts b/packages/web/lib/hooks/useSolutions.ts index 69d9de3b..7d577e10 100644 --- a/packages/web/lib/hooks/useSolutions.ts +++ b/packages/web/lib/hooks/useSolutions.ts @@ -29,20 +29,13 @@ import type { MetadataResponse, AffiliateLinkResponse, } from "@/lib/api/generated/models"; -import { supabaseBrowserClient } from "@/lib/supabase/client"; - /** - * Cache Invalidation Boundaries (MIG-09) + * Cache Invalidation Boundaries * * REST API cache (Orval generated hooks): * Keys: solutionKeys, spotKeys, commentKeys, postKeys.lists() * Invalidated by: REST mutations below * - * Supabase direct query cache: - * Keys: postKeys.detail(id) via fetchPostWithSpotsAndSolutions - * Cross-boundary: useAdoptSolution/useUnadoptSolution explicitly - * invalidate ["posts", "detail"] — this is intentional. - * * Server-side fetches (fetchPostsServer): * No React Query cache — uses Next.js revalidate header only. */ @@ -82,39 +75,11 @@ export function useSolutions( // useAllSolutionsForSpots - Fetch solutions for multiple spots // ============================================================ -/** Solutions grouped by spot ID */ -/** Fetch solutions for a spot via Supabase (no Rust backend needed) */ -async function fetchSolutionsFromSupabase(spotId: string): Promise { - const { data, error } = await supabaseBrowserClient - .from("solutions") - .select("*, profiles:user_id(id, username, avatar_url)") - .eq("spot_id", spotId) - .order("created_at", { ascending: false }); - - if (error) throw new Error(error.message); - return (data ?? []).map((row: any) => ({ - id: row.id, - title: row.title ?? "", - thumbnail_url: row.thumbnail_url, - original_url: row.original_url, - affiliate_url: row.affiliate_url, - link_type: row.link_type, - metadata: row.metadata, - brand_id: row.brand_id, - match_type: row.match_type, - is_verified: row.is_verified ?? false, - is_adopted: row.is_adopted ?? false, - created_at: row.created_at, - vote_stats: { accurate: 0, different: 0 }, - user: { id: row.user_id, username: "", email: "", rank: "Member", total_points: 0, followers_count: 0, following_count: 0, is_admin: false }, - } as GeneratedSolutionListItem)); -} - export function useAllSolutionsForSpots(spotIds: string[]) { const results = useQueries({ queries: spotIds.map((spotId) => ({ queryKey: solutionKeys.list(spotId), - queryFn: () => fetchSolutionsFromSupabase(spotId), + queryFn: () => listSolutions(spotId), enabled: !!spotId, staleTime: 1000 * 60, })), diff --git a/packages/web/lib/stores/authStore.ts b/packages/web/lib/stores/authStore.ts index a6f30cce..475972f5 100644 --- a/packages/web/lib/stores/authStore.ts +++ b/packages/web/lib/stores/authStore.ts @@ -5,6 +5,8 @@ import { create } from "zustand"; import { type User as SupabaseUser } from "@supabase/supabase-js"; import { supabaseBrowserClient } from "@/lib/supabase/client"; +import { getMyProfile, updateMyProfile } from "@/lib/api/generated/users/users"; +import type { UpdateUserDto } from "@/lib/api/generated/models"; export type OAuthProvider = "kakao" | "google" | "apple"; @@ -243,30 +245,28 @@ export const useAuthStore = create((set, get) => ({ }, /** - * public.users 프로필 데이터 가져오기 + * 백엔드 API를 통해 프로필 데이터 가져오기 */ fetchProfile: async () => { const user = get().user; if (!user) return; try { - const { data, error } = await supabaseBrowserClient - .from("users") - .select("*") - .eq("id", user.id) - .single(); - - if (error) { - if (error.code === "PGRST116") { - console.log("[authStore] New user detected, needs onboarding"); - set({ needsOnboarding: true, profile: null }); - return; - } - console.error("Failed to fetch profile:", error); - return; - } - - const profile = data as unknown as UserProfile; + const data = await getMyProfile(); + + const profile: UserProfile = { + id: data.id, + email: data.email, + username: data.username, + display_name: data.display_name ?? null, + avatar_url: data.avatar_url ?? null, + bio: data.bio ?? null, + rank: data.rank ?? null, + total_points: data.total_points, + is_admin: data.is_admin, + created_at: "", + updated_at: "", + }; // Detect first-time user: username and display_name are both email-prefix defaults const emailPrefix = user.email.split("@")[0] || ""; @@ -279,13 +279,19 @@ export const useAuthStore = create((set, get) => ({ isAdmin: profile.is_admin === true, needsOnboarding: isDefault, }); - } catch (error) { + } catch (error: unknown) { + // 404 = new user not yet in users table + if (error && typeof error === "object" && "status" in error && (error as { status: number }).status === 404) { + console.log("[authStore] New user detected, needs onboarding"); + set({ needsOnboarding: true, profile: null }); + return; + } console.error("Profile fetch error:", error); } }, /** - * public.users 프로필 업데이트 + * 백엔드 API를 통해 프로필 업데이트 */ updateProfile: async ( updates: Partial> @@ -294,16 +300,7 @@ export const useAuthStore = create((set, get) => ({ if (!user) return false; try { - const { error } = await supabaseBrowserClient - .from("users") - .update(updates as Record) - .eq("id", user.id); - - if (error) { - console.error("Failed to update profile:", error); - return false; - } - + await updateMyProfile(updates as UpdateUserDto); // Re-fetch profile to get updated data await get().fetchProfile(); return true; diff --git a/packages/web/lib/supabase/queries/debug/posts.ts b/packages/web/lib/supabase/queries/debug/posts.ts deleted file mode 100644 index 36748374..00000000 --- a/packages/web/lib/supabase/queries/debug/posts.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * DEBUG ONLY: Query layer for posts table (client-side) - * - * This module is for debugging/reference purposes only. - * Production code should use the main queries module instead. - * - * Schema update (2026-01-29): 'post' table → 'posts' table - */ - -import { supabaseBrowserClient } from "../../client"; -import type { PostRow } from "../../types"; - -/** - * Fetches the latest posts from the database (client-side) - * - * @param limit - Maximum number of posts to fetch (default: 10) - * @returns Array of post rows, ordered by created_at descending - * @throws Error if the query fails - */ -export async function fetchLatestPosts(limit = 10): Promise { - const { data, error } = await supabaseBrowserClient - .from("posts") - .select("*") - .eq("status", "active") - .order("created_at", { ascending: false }) - .limit(limit); - - if (error) { - throw error; - } - - return data ?? []; -} diff --git a/packages/web/lib/supabase/storage.ts b/packages/web/lib/supabase/storage.ts deleted file mode 100644 index 05cd28d4..00000000 --- a/packages/web/lib/supabase/storage.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Supabase Storage 유틸리티 - * 이미지 업로드 및 URL 관리 - */ - -import { supabaseBrowserClient } from "./client"; - -const BUCKET_NAME = "request-images"; - -/** - * 고유한 파일 경로 생성 - */ -function generateFilePath(file: File): string { - const timestamp = Date.now(); - const randomId = Math.random().toString(36).slice(2, 9); - const extension = file.name.split(".").pop() || "jpg"; - return `temp/${timestamp}_${randomId}.${extension}`; -} - -/** - * Supabase Storage에 이미지 업로드 - * @param file - 업로드할 파일 - * @returns 공개 URL - */ -export async function uploadToSupabaseStorage(file: File): Promise { - const filePath = generateFilePath(file); - - const { error: uploadError } = await supabaseBrowserClient.storage - .from(BUCKET_NAME) - .upload(filePath, file, { - cacheControl: "31536000, immutable", - upsert: false, - }); - - if (uploadError) { - console.error("Supabase Storage upload error:", uploadError); - throw new Error(`이미지 업로드 실패: ${uploadError.message}`); - } - - // 공개 URL 가져오기 - const { - data: { publicUrl }, - } = supabaseBrowserClient.storage.from(BUCKET_NAME).getPublicUrl(filePath); - - return publicUrl; -} - -/** - * Supabase Storage에서 이미지 삭제 - * @param url - 삭제할 이미지의 공개 URL - */ -export async function deleteFromSupabaseStorage(url: string): Promise { - // URL에서 파일 경로 추출 - const urlParts = url.split(`${BUCKET_NAME}/`); - if (urlParts.length < 2) { - console.warn("Invalid storage URL format:", url); - return; - } - - const filePath = urlParts[1]; - - const { error } = await supabaseBrowserClient.storage - .from(BUCKET_NAME) - .remove([filePath]); - - if (error) { - console.error("Failed to delete from storage:", error); - } -}