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
parent
e011ef7357
commit
7fe3ff82c9
|
|
@ -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 }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" } },
|
||||||
|
|
|
||||||
|
|
@ -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 || "",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue