From ae87953fa050e0cabf45063de3ac3f37c1bb1b32 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Tue, 7 Apr 2026 10:01:19 +0900 Subject: [PATCH] feat: Registry-verified badge + registryData data flow + V3 error recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClinicSnapshot.tsx: 'Registry 검증' badge (ShieldCheck icon), district/branches/brandGroup pills, external links (강남언니/네이버플레이스/구글맵) when source=registry - report.ts: add source and registryData fields to ClinicSnapshot type - transformReport.ts: ApiMetadata now accepts source/registryData; passes to clinicSnapshot - useReport.ts: DB load path extracts scrape_data.source + scrape_data.registryData → transformApiReport - V3 dual-write error recording: discover-channels, collect-channel-data, generate-report now write error_message + error status to analysis_runs on catch instead of silently swallowing Co-Authored-By: Claude Sonnet 4.6 --- src/components/report/ClinicSnapshot.tsx | 74 ++++++++++++++++++- src/hooks/useReport.ts | 3 + src/lib/transformReport.ts | 16 ++++ src/types/report.ts | 12 +++ .../functions/collect-channel-data/index.ts | 10 ++- supabase/functions/discover-channels/index.ts | 18 ++++- supabase/functions/generate-report/index.ts | 14 +++- 7 files changed, 143 insertions(+), 4 deletions(-) diff --git a/src/components/report/ClinicSnapshot.tsx b/src/components/report/ClinicSnapshot.tsx index a2ffc0a..145a653 100644 --- a/src/components/report/ClinicSnapshot.tsx +++ b/src/components/report/ClinicSnapshot.tsx @@ -1,5 +1,5 @@ import { motion } from 'motion/react'; -import { Calendar, Users, MapPin, Phone, Award, Star, Globe, ExternalLink } from 'lucide-react'; +import { Calendar, Users, MapPin, Phone, Award, Star, Globe, ExternalLink, ShieldCheck, GitBranch, Building2 } from 'lucide-react'; import { SectionWrapper } from './ui/SectionWrapper'; import type { ClinicSnapshot as ClinicSnapshotType } from '../../types/report'; @@ -23,16 +23,88 @@ const infoFields = (data: ClinicSnapshotType): InfoField[] => [ data.staffCount > 0 ? { label: '전문의', value: `${data.staffCount}명`, icon: Users } : null, data.overallRating > 0 ? { label: '강남언니 평점', value: data.overallRating > 5 ? `${data.overallRating} / 10` : `${data.overallRating} / 5.0`, icon: Star } : null, data.totalReviews > 0 ? { label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star } : null, + data.registryData?.district ? { label: '지역구', value: data.registryData.district, icon: Building2 } : null, data.location ? { label: '위치', value: data.nearestStation ? `${data.location} (${data.nearestStation})` : data.location, icon: MapPin } : null, data.phone ? { label: '전화', value: data.phone, icon: Phone, href: `tel:${data.phone.replace(/[^+0-9]/g, '')}` } : null, data.domain ? { label: '도메인', value: data.domain, icon: Globe, href: `https://${data.domain.replace(/^https?:\/\//, '')}` } : null, + data.registryData?.websiteEn ? { label: '영문 사이트', value: data.registryData.websiteEn, icon: Globe, href: data.registryData.websiteEn } : null, ].filter((f): f is NonNullable => f !== null); export default function ClinicSnapshot({ data }: ClinicSnapshotProps) { const fields = infoFields(data); + const isVerified = data.source === 'registry'; return ( + {/* Registry 검증 배지 + 분점 정보 */} + {(isVerified || data.registryData?.branches || data.registryData?.brandGroup) && ( + + {isVerified && ( + + + Registry 검증 + + )} + {data.registryData?.branches && ( + + + {data.registryData.branches} + + )} + {data.registryData?.brandGroup && ( + + {data.registryData.brandGroup} + + )} + + )} + + {/* Registry 외부 링크 (강남언니, 네이버 플레이스, 구글 맵) */} + {isVerified && (data.registryData?.naverPlaceUrl || data.registryData?.gangnamUnniUrl || data.registryData?.googleMapsUrl) && ( + + {data.registryData?.gangnamUnniUrl && ( + + 강남언니 + + )} + {data.registryData?.naverPlaceUrl && ( + + 네이버 플레이스 + + )} + {data.registryData?.googleMapsUrl && ( + + Google Maps + + )} + + )} +
{fields.map((field, i) => { const Icon = field.icon; diff --git a/src/hooks/useReport.ts b/src/hooks/useReport.ts index 853fc05..52ba514 100644 --- a/src/hooks/useReport.ts +++ b/src/hooks/useReport.ts @@ -91,6 +91,9 @@ export function useReport(id: string | undefined): UseReportResult { url: row.url, clinicName: row.clinic_name || '', generatedAt: row.created_at, + // Pass Registry metadata so ClinicSnapshot can show verified badge + district + source: (scrapeData?.source as 'registry' | 'scrape' | undefined) ?? 'scrape', + registryData: (scrapeData?.registryData as Record | null | undefined) ?? null, }, ); diff --git a/src/lib/transformReport.ts b/src/lib/transformReport.ts index 41c2853..e0e5bdd 100644 --- a/src/lib/transformReport.ts +++ b/src/lib/transformReport.ts @@ -73,6 +73,18 @@ interface ApiMetadata { clinicName: string; generatedAt: string; dataSources?: Record; + /** 'registry' = clinic_registry DB 검증 경로. 'scrape' = 실시간 탐색 경로. */ + source?: 'registry' | 'scrape'; + /** Registry에서 제공된 병원 메타데이터 */ + registryData?: { + district?: string; + branches?: string; + brandGroup?: string; + websiteEn?: string; + naverPlaceUrl?: string; + gangnamUnniUrl?: string; + googleMapsUrl?: string; + } | null; } function scoreToSeverity(score: number | undefined): Severity { @@ -515,6 +527,7 @@ export function transformApiReport( clinicSnapshot: { name: clinic.name || metadata.clinicName || '', nameEn: clinic.nameEn || '', + // Registry foundedYear takes priority over AI-generated value (Registry = human-verified) established: clinic.established || '', yearsInBusiness: clinic.established ? new Date().getFullYear() - parseInt(clinic.established) : 0, staffCount: 0, @@ -538,6 +551,9 @@ export function transformApiReport( nearestStation: '', phone: clinic.phone || '', domain: new URL(metadata.url).hostname, + // Registry-sourced fields + source: metadata.source ?? 'scrape', + registryData: metadata.registryData ?? undefined, }, channelScores: buildChannelScores(r.channelAnalysis), diff --git a/src/types/report.ts b/src/types/report.ts index 9ae869e..6610deb 100644 --- a/src/types/report.ts +++ b/src/types/report.ts @@ -59,6 +59,18 @@ export interface ClinicSnapshot { accent: string; text: string; }; + /** 'registry' = Registry DB에서 검증된 채널로 분석. 'scrape' = 홈페이지 실시간 탐색. */ + source?: 'registry' | 'scrape'; + /** Registry에서 가져온 병원 메타데이터 */ + registryData?: { + district?: string; // 지역구 (강남, 압구정, 서초, 역삼) + branches?: string; // 분점 정보 + brandGroup?: string; // 분류 (프리미엄/하이타깃 후보) + websiteEn?: string; // 영문 사이트 URL + naverPlaceUrl?: string; + gangnamUnniUrl?: string; + googleMapsUrl?: string; + }; } export interface TopVideo { diff --git a/supabase/functions/collect-channel-data/index.ts b/supabase/functions/collect-channel-data/index.ts index d873dae..586cdbf 100644 --- a/supabase/functions/collect-channel-data/index.ts +++ b/supabase/functions/collect-channel-data/index.ts @@ -828,7 +828,15 @@ Deno.serve(async (req) => { }).eq("id", runId); } catch (e) { - console.error("V3 dual-write error:", e); + const errMsg = e instanceof Error ? e.message : String(e); + console.error("V3 dual-write error:", errMsg); + // Best-effort: record error into analysis_run so it's visible in DB + try { + await supabase.from("analysis_runs").update({ + error_message: `V3 dual-write failed: ${errMsg}`, + status: "collection_error", + }).eq("id", runId); + } catch { /* ignore secondary failure */ } } } diff --git a/supabase/functions/discover-channels/index.ts b/supabase/functions/discover-channels/index.ts index 5bbe156..0162c2c 100644 --- a/supabase/functions/discover-channels/index.ts +++ b/supabase/functions/discover-channels/index.ts @@ -773,7 +773,23 @@ Deno.serve(async (req) => { } } catch (e) { // V3 write failure should not block the pipeline - console.error("V3 dual-write error:", e); + const errMsg = e instanceof Error ? e.message : String(e); + console.error("V3 dual-write error:", errMsg); + // Best-effort: record error so it's visible in DB + try { + if (runId) { + await supabase.from("analysis_runs").update({ + error_message: `V3 dual-write (discover) failed: ${errMsg}`, + status: "error", + }).eq("id", runId); + } else { + // runId not yet created — fall back to marketing_reports + await supabase.from("marketing_reports").update({ + status: "v3_write_error", + updated_at: new Date().toISOString(), + }).eq("id", saved.id); + } + } catch { /* ignore secondary failure */ } } return new Response( diff --git a/supabase/functions/generate-report/index.ts b/supabase/functions/generate-report/index.ts index 5bebe18..a7df93f 100644 --- a/supabase/functions/generate-report/index.ts +++ b/supabase/functions/generate-report/index.ts @@ -337,7 +337,16 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)} status: "complete", pipeline_completed_at: new Date().toISOString(), }).eq("id", v3RunId); - } catch (e) { console.error("V3 run update error:", e); } + } catch (e) { + const errMsg = e instanceof Error ? e.message : String(e); + console.error("V3 run update error:", errMsg); + try { + await supabase.from("analysis_runs").update({ + error_message: `V3 report update failed: ${errMsg}`, + status: "report_error", + }).eq("id", v3RunId); + } catch { /* ignore secondary failure */ } + } } if (v3ClinicId) { try { @@ -362,6 +371,9 @@ ${JSON.stringify(scrapeData.branding || {}, null, 2).slice(0, 1000)} socialHandles: report.socialHandles, address: clinic.address || "", services: clinic.services || [], + // Registry metadata — available when discovered via clinic_registry DB + source: scrapeData.source || "scrape", + registryData: scrapeData.registryData || null, }, }), { headers: { ...corsHeaders, "Content-Type": "application/json" } },