From 7fe3ff82c9782ac2dc63af8d51ab016e848ad81e Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Sun, 5 Apr 2026 00:51:11 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20DB=20V3=20dual-write=20=E2=80=94=20clin?= =?UTF-8?q?ics=20+=20analysis=5Fruns=20+=20channel=5Fsnapshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2-4 of SaaS schema migration. All Edge Functions now write to BOTH legacy marketing_reports AND new V3 tables: discover-channels: - UPSERT clinics (url-based dedup) - INSERT analysis_runs (status: discovering) collect-channel-data: - INSERT channel_snapshots (one per channel — time-series!) - INSERT screenshots (evidence rows) - UPDATE analysis_runs (raw_channel_data, vision_analysis) generate-report: - UPDATE analysis_runs (report, status: complete) - UPDATE clinics (last_analyzed_at, established_year) Frontend passes clinicId + runId through all 3 phases. Legacy marketing_reports still written for backward compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/supabase.ts | 8 +- src/pages/AnalysisLoadingPage.tsx | 6 +- .../functions/collect-channel-data/index.ts | 98 ++++++++++++++++++- supabase/functions/discover-channels/index.ts | 54 ++++++++++ supabase/functions/generate-report/index.ts | 26 +++++ 5 files changed, 184 insertions(+), 8 deletions(-) diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index 8e3b6ee..20bdb84 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -108,13 +108,13 @@ export async function discoverChannels(url: string, clinicName?: string) { * Phase 2: Collect all channel data using verified handles. * Reads verified_channels from DB, runs parallel API calls. */ -export async function collectChannelData(reportId: string) { +export async function collectChannelData(reportId: string, clinicId?: string, runId?: string) { const response = await fetch( `${supabaseUrl}/functions/v1/collect-channel-data`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reportId }), + body: JSON.stringify({ reportId, clinicId, runId }), } ); @@ -128,13 +128,13 @@ export async function collectChannelData(reportId: string) { /** * Phase 3: Generate AI report using real collected data from DB. */ -export async function generateReportV2(reportId: string) { +export async function generateReportV2(reportId: string, clinicId?: string, runId?: string) { const response = await fetch( `${supabaseUrl}/functions/v1/generate-report`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ reportId }), + body: JSON.stringify({ reportId, clinicId, runId }), } ); diff --git a/src/pages/AnalysisLoadingPage.tsx b/src/pages/AnalysisLoadingPage.tsx index b4a2cd7..51c64d5 100644 --- a/src/pages/AnalysisLoadingPage.tsx +++ b/src/pages/AnalysisLoadingPage.tsx @@ -39,15 +39,17 @@ export default function AnalysisLoadingPage() { const discovery = await discoverChannels(url); if (!discovery.success) throw new Error(discovery.error || 'Channel discovery failed'); const reportId = discovery.reportId; + const clinicId = discovery.clinicId; // V3 + const runId = discovery.runId; // V3 // Phase 2: Collect Channel Data setPhase('collecting'); - const collection = await collectChannelData(reportId); + const collection = await collectChannelData(reportId, clinicId, runId); if (!collection.success) throw new Error(collection.error || 'Data collection failed'); // Phase 3: Generate Report setPhase('generating'); - const result = await generateReportV2(reportId); + const result = await generateReportV2(reportId, clinicId, runId); if (!result.success) throw new Error(result.error || 'Report generation failed'); // Complete — navigate to report diff --git a/supabase/functions/collect-channel-data/index.ts b/supabase/functions/collect-channel-data/index.ts index 53c0bd8..cb3ba9a 100644 --- a/supabase/functions/collect-channel-data/index.ts +++ b/supabase/functions/collect-channel-data/index.ts @@ -13,6 +13,8 @@ const APIFY_BASE = "https://api.apify.com/v2"; interface CollectRequest { reportId: string; + clinicId?: string; // V3: clinic UUID + runId?: string; // V3: analysis_run UUID } async function runApifyActor(actorId: string, input: Record, token: string): Promise { @@ -40,7 +42,7 @@ Deno.serve(async (req) => { } try { - const { reportId } = (await req.json()) as CollectRequest; + const { reportId, clinicId: inputClinicId, runId: inputRunId } = (await req.json()) as CollectRequest; if (!reportId) throw new Error("reportId is required"); // Read Phase 1 results from DB @@ -390,7 +392,7 @@ Deno.serve(async (req) => { // ─── Execute all tasks ─── await Promise.allSettled(tasks); - // ─── Save to DB ─── + // ─── Legacy: Save to marketing_reports ─── await supabase.from("marketing_reports").update({ channel_data: channelData, analysis_data: { clinicName, services, address, analysis: analysisData, analyzedAt: new Date().toISOString() }, @@ -398,6 +400,98 @@ Deno.serve(async (req) => { updated_at: new Date().toISOString(), }).eq("id", reportId); + // ─── V3: channel_snapshots + screenshots + analysis_runs ─── + const clinicId = inputClinicId || null; + const runId = inputRunId || null; + + if (clinicId && runId) { + try { + // Channel snapshots — INSERT one row per channel (time-series!) + const snapshotInserts: Record[] = []; + + const igData = channelData.instagram as Record | undefined; + if (igData) { + snapshotInserts.push({ + clinic_id: clinicId, run_id: runId, channel: 'instagram', + handle: igData.username, followers: igData.followers, posts: igData.posts, + details: igData, + }); + } + + const ytData = channelData.youtube as Record | undefined; + if (ytData) { + snapshotInserts.push({ + clinic_id: clinicId, run_id: runId, channel: 'youtube', + handle: ytData.handle || ytData.channelName, followers: ytData.subscribers, + posts: ytData.totalVideos, total_views: ytData.totalViews, + details: ytData, + }); + } + + const fbData = channelData.facebook as Record | undefined; + if (fbData) { + snapshotInserts.push({ + clinic_id: clinicId, run_id: runId, channel: 'facebook', + handle: fbData.pageName, followers: fbData.followers, + details: fbData, + }); + } + + const guData = channelData.gangnamUnni as Record | undefined; + if (guData) { + snapshotInserts.push({ + clinic_id: clinicId, run_id: runId, channel: 'gangnamUnni', + handle: guData.name, rating: guData.rating, rating_scale: 10, + reviews: guData.totalReviews, details: guData, + }); + } + + const gmData = channelData.googleMaps as Record | undefined; + if (gmData) { + snapshotInserts.push({ + clinic_id: clinicId, run_id: runId, channel: 'googleMaps', + handle: gmData.name, rating: gmData.rating, rating_scale: 5, + reviews: gmData.reviewCount, details: gmData, + }); + } + + const nbData = channelData.naverBlog as Record | undefined; + if (nbData) { + snapshotInserts.push({ + clinic_id: clinicId, run_id: runId, channel: 'naverBlog', + handle: nbData.officialBlogHandle, details: nbData, + }); + } + + if (snapshotInserts.length > 0) { + await supabase.from("channel_snapshots").insert(snapshotInserts); + } + + // Screenshots — INSERT evidence rows + const screenshotList = (channelData.screenshots || []) as Record[]; + if (screenshotList.length > 0) { + await supabase.from("screenshots").insert( + screenshotList.map(ss => ({ + clinic_id: clinicId, run_id: runId, + channel: ss.channel, page_type: (ss.id as string || '').split('-')[1] || 'main', + url: ss.url, source_url: ss.sourceUrl, caption: ss.caption, + })) + ); + } + + // Update analysis_run + await supabase.from("analysis_runs").update({ + raw_channel_data: channelData, + analysis_data: { clinicName, services, address, analysis: analysisData }, + vision_analysis: channelData.visionAnalysis || {}, + status: "collecting", + }).eq("id", runId); + + } catch (e) { + console.error("V3 dual-write error:", e); + } + } + return new Response( JSON.stringify({ success: true, channelData, analysisData, collectedAt: new Date().toISOString() }), { headers: { ...corsHeaders, "Content-Type": "application/json" } }, diff --git a/supabase/functions/discover-channels/index.ts b/supabase/functions/discover-channels/index.ts index 05da48e..73cc888 100644 --- a/supabase/functions/discover-channels/index.ts +++ b/supabase/functions/discover-channels/index.ts @@ -486,6 +486,7 @@ Deno.serve(async (req) => { onlinePresenceResearch: perplexityResearch, }; + // ─── Legacy: marketing_reports (backward compat) ─── const { data: saved, error: saveError } = await supabase .from("marketing_reports") .insert({ @@ -501,9 +502,62 @@ Deno.serve(async (req) => { if (saveError) throw new Error(`DB save failed: ${saveError.message}`); + // ─── V3: clinics + analysis_runs (dual-write) ─── + let clinicId: string | null = null; + let runId: string | null = null; + try { + // UPSERT clinic (url 기준 — 같은 URL이면 기존 행 업데이트) + const { data: clinicRow } = await supabase + .from("clinics") + .upsert({ + url, + name: resolvedName, + name_en: clinic.clinicNameEn || null, + domain: new URL(url).hostname.replace('www.', ''), + address: clinic.address || null, + phone: clinic.phone || null, + services: clinic.services || [], + branding: brandData.data?.json || {}, + social_handles: { + instagram: verified.instagram?.map((v: Record) => v.handle) || [], + youtube: (verified.youtube as Record)?.handle || null, + facebook: (verified.facebook as Record)?.handle || null, + naverBlog: (verified.naverBlog as Record)?.handle || null, + }, + verified_channels: verified, + last_analyzed_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, { onConflict: 'url' }) + .select("id") + .single(); + + clinicId = clinicRow?.id || null; + + // INSERT analysis_run + if (clinicId) { + const { data: runRow } = await supabase + .from("analysis_runs") + .insert({ + clinic_id: clinicId, + status: "discovering", + scrape_data: scrapeDataFull, + discovered_channels: verified, + trigger: "manual", + pipeline_started_at: new Date().toISOString(), + }) + .select("id") + .single(); + runId = runRow?.id || null; + } + } catch (e) { + // V3 write failure should not block the pipeline + console.error("V3 dual-write error:", e); + } + return new Response( JSON.stringify({ success: true, reportId: saved.id, + clinicId, runId, // V3 IDs for downstream phases clinicName: resolvedName, verifiedChannels: verified, address: clinic.address || "", diff --git a/supabase/functions/generate-report/index.ts b/supabase/functions/generate-report/index.ts index f91b0d2..b4f7343 100644 --- a/supabase/functions/generate-report/index.ts +++ b/supabase/functions/generate-report/index.ts @@ -12,6 +12,9 @@ const corsHeaders = { interface ReportRequest { // V2: reportId-based (Phase 3 — uses data already in DB) reportId?: string; + // V3: clinic + run IDs for new schema + clinicId?: string; + runId?: string; // V1 compat: url-based (legacy single-call flow) url?: string; clinicName?: string; @@ -153,6 +156,7 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)} facebook: verified.facebook?.verified ? verified.facebook.handle : null, }; + // Legacy: marketing_reports await supabase.from("marketing_reports").update({ report, status: "complete", @@ -160,6 +164,28 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)} updated_at: new Date().toISOString(), }).eq("id", body.reportId); + // V3: analysis_runs + clinics + const v3RunId = body.runId || null; + const v3ClinicId = body.clinicId || null; + if (v3RunId) { + try { + await supabase.from("analysis_runs").update({ + report, + status: "complete", + pipeline_completed_at: new Date().toISOString(), + }).eq("id", v3RunId); + } catch (e) { console.error("V3 run update error:", e); } + } + if (v3ClinicId) { + try { + await supabase.from("clinics").update({ + last_analyzed_at: new Date().toISOString(), + established_year: report.clinicInfo?.established ? parseInt(report.clinicInfo.established) || null : null, + updated_at: new Date().toISOString(), + }).eq("id", v3ClinicId); + } catch (e) { console.error("V3 clinic update error:", e); } + } + return new Response( JSON.stringify({ success: true,