feat: DB V3 dual-write — clinics + analysis_runs + channel_snapshots

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) <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-05 00:51:11 +09:00
parent e011ef7357
commit 7fe3ff82c9
5 changed files with 184 additions and 8 deletions

View File

@ -108,13 +108,13 @@ export async function discoverChannels(url: string, clinicName?: string) {
* Phase 2: Collect all channel data using verified handles. * Phase 2: Collect all channel data using verified handles.
* Reads verified_channels from DB, runs parallel API calls. * 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( const response = await fetch(
`${supabaseUrl}/functions/v1/collect-channel-data`, `${supabaseUrl}/functions/v1/collect-channel-data`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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. * 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( const response = await fetch(
`${supabaseUrl}/functions/v1/generate-report`, `${supabaseUrl}/functions/v1/generate-report`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reportId }), body: JSON.stringify({ reportId, clinicId, runId }),
} }
); );

View File

@ -39,15 +39,17 @@ export default function AnalysisLoadingPage() {
const discovery = await discoverChannels(url); const discovery = await discoverChannels(url);
if (!discovery.success) throw new Error(discovery.error || 'Channel discovery failed'); if (!discovery.success) throw new Error(discovery.error || 'Channel discovery failed');
const reportId = discovery.reportId; const reportId = discovery.reportId;
const clinicId = discovery.clinicId; // V3
const runId = discovery.runId; // V3
// Phase 2: Collect Channel Data // Phase 2: Collect Channel Data
setPhase('collecting'); 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'); if (!collection.success) throw new Error(collection.error || 'Data collection failed');
// Phase 3: Generate Report // Phase 3: Generate Report
setPhase('generating'); 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'); if (!result.success) throw new Error(result.error || 'Report generation failed');
// Complete — navigate to report // Complete — navigate to report

View File

@ -13,6 +13,8 @@ const APIFY_BASE = "https://api.apify.com/v2";
interface CollectRequest { interface CollectRequest {
reportId: string; reportId: string;
clinicId?: string; // V3: clinic UUID
runId?: string; // V3: analysis_run UUID
} }
async function runApifyActor(actorId: string, input: Record<string, unknown>, token: string): Promise<unknown[]> { async function runApifyActor(actorId: string, input: Record<string, unknown>, token: string): Promise<unknown[]> {
@ -40,7 +42,7 @@ Deno.serve(async (req) => {
} }
try { 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"); if (!reportId) throw new Error("reportId is required");
// Read Phase 1 results from DB // Read Phase 1 results from DB
@ -390,7 +392,7 @@ Deno.serve(async (req) => {
// ─── Execute all tasks ─── // ─── Execute all tasks ───
await Promise.allSettled(tasks); await Promise.allSettled(tasks);
// ─── Save to DB ─── // ─── Legacy: Save to marketing_reports ───
await supabase.from("marketing_reports").update({ await supabase.from("marketing_reports").update({
channel_data: channelData, channel_data: channelData,
analysis_data: { clinicName, services, address, analysis: analysisData, analyzedAt: new Date().toISOString() }, analysis_data: { clinicName, services, address, analysis: analysisData, analyzedAt: new Date().toISOString() },
@ -398,6 +400,98 @@ Deno.serve(async (req) => {
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}).eq("id", reportId); }).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<string, unknown>[] = [];
const igData = channelData.instagram as Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown>[];
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( return new Response(
JSON.stringify({ success: true, channelData, analysisData, collectedAt: new Date().toISOString() }), JSON.stringify({ success: true, channelData, analysisData, collectedAt: new Date().toISOString() }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }, { headers: { ...corsHeaders, "Content-Type": "application/json" } },

View File

@ -486,6 +486,7 @@ Deno.serve(async (req) => {
onlinePresenceResearch: perplexityResearch, onlinePresenceResearch: perplexityResearch,
}; };
// ─── Legacy: marketing_reports (backward compat) ───
const { data: saved, error: saveError } = await supabase const { data: saved, error: saveError } = await supabase
.from("marketing_reports") .from("marketing_reports")
.insert({ .insert({
@ -501,9 +502,62 @@ Deno.serve(async (req) => {
if (saveError) throw new Error(`DB save failed: ${saveError.message}`); 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<string, unknown>) => v.handle) || [],
youtube: (verified.youtube as Record<string, unknown>)?.handle || null,
facebook: (verified.facebook as Record<string, unknown>)?.handle || null,
naverBlog: (verified.naverBlog as Record<string, unknown>)?.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( return new Response(
JSON.stringify({ JSON.stringify({
success: true, reportId: saved.id, success: true, reportId: saved.id,
clinicId, runId, // V3 IDs for downstream phases
clinicName: resolvedName, clinicName: resolvedName,
verifiedChannels: verified, verifiedChannels: verified,
address: clinic.address || "", address: clinic.address || "",

View File

@ -12,6 +12,9 @@ const corsHeaders = {
interface ReportRequest { interface ReportRequest {
// V2: reportId-based (Phase 3 — uses data already in DB) // V2: reportId-based (Phase 3 — uses data already in DB)
reportId?: string; reportId?: string;
// V3: clinic + run IDs for new schema
clinicId?: string;
runId?: string;
// V1 compat: url-based (legacy single-call flow) // V1 compat: url-based (legacy single-call flow)
url?: string; url?: string;
clinicName?: string; clinicName?: string;
@ -153,6 +156,7 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
facebook: verified.facebook?.verified ? verified.facebook.handle : null, facebook: verified.facebook?.verified ? verified.facebook.handle : null,
}; };
// Legacy: marketing_reports
await supabase.from("marketing_reports").update({ await supabase.from("marketing_reports").update({
report, report,
status: "complete", status: "complete",
@ -160,6 +164,28 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)}
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}).eq("id", body.reportId); }).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( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,