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.
|
||||
* 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 }),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>, token: string): Promise<unknown[]> {
|
||||
|
|
@ -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<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(
|
||||
JSON.stringify({ success: true, channelData, analysisData, collectedAt: new Date().toISOString() }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
JSON.stringify({
|
||||
success: true, reportId: saved.id,
|
||||
clinicId, runId, // V3 IDs for downstream phases
|
||||
clinicName: resolvedName,
|
||||
verifiedChannels: verified,
|
||||
address: clinic.address || "",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue