feat: Registry-verified badge + registryData data flow + V3 error recording
- 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 <noreply@anthropic.com>claude/bold-hawking
parent
36d2f1cf49
commit
ae87953fa0
|
|
@ -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<InfoField> => f !== null);
|
||||
|
||||
export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
|
||||
const fields = infoFields(data);
|
||||
const isVerified = data.source === 'registry';
|
||||
|
||||
return (
|
||||
<SectionWrapper id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보">
|
||||
{/* Registry 검증 배지 + 분점 정보 */}
|
||||
{(isVerified || data.registryData?.branches || data.registryData?.brandGroup) && (
|
||||
<motion.div
|
||||
className="flex flex-wrap items-center gap-2 mb-5"
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{isVerified && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 border border-emerald-200 px-3 py-1 text-xs font-semibold text-emerald-700">
|
||||
<ShieldCheck size={13} className="text-emerald-600" />
|
||||
Registry 검증
|
||||
</span>
|
||||
)}
|
||||
{data.registryData?.branches && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 border border-blue-200 px-3 py-1 text-xs font-medium text-blue-700">
|
||||
<GitBranch size={12} className="text-blue-500" />
|
||||
{data.registryData.branches}
|
||||
</span>
|
||||
)}
|
||||
{data.registryData?.brandGroup && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-violet-50 border border-violet-200 px-3 py-1 text-xs font-medium text-violet-700">
|
||||
{data.registryData.brandGroup}
|
||||
</span>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Registry 외부 링크 (강남언니, 네이버 플레이스, 구글 맵) */}
|
||||
{isVerified && (data.registryData?.naverPlaceUrl || data.registryData?.gangnamUnniUrl || data.registryData?.googleMapsUrl) && (
|
||||
<motion.div
|
||||
className="flex flex-wrap gap-2 mb-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.1 }}
|
||||
>
|
||||
{data.registryData?.gangnamUnniUrl && (
|
||||
<a
|
||||
href={data.registryData.gangnamUnniUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-pink-50 border border-pink-200 px-3 py-1.5 text-xs font-medium text-pink-700 hover:bg-pink-100 transition-colors"
|
||||
>
|
||||
강남언니 <ExternalLink size={11} />
|
||||
</a>
|
||||
)}
|
||||
{data.registryData?.naverPlaceUrl && (
|
||||
<a
|
||||
href={data.registryData.naverPlaceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-green-50 border border-green-200 px-3 py-1.5 text-xs font-medium text-green-700 hover:bg-green-100 transition-colors"
|
||||
>
|
||||
네이버 플레이스 <ExternalLink size={11} />
|
||||
</a>
|
||||
)}
|
||||
{data.registryData?.googleMapsUrl && (
|
||||
<a
|
||||
href={data.registryData.googleMapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-50 border border-blue-200 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
Google Maps <ExternalLink size={11} />
|
||||
</a>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||
{fields.map((field, i) => {
|
||||
const Icon = field.icon;
|
||||
|
|
|
|||
|
|
@ -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<string, string> | null | undefined) ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,18 @@ interface ApiMetadata {
|
|||
clinicName: string;
|
||||
generatedAt: string;
|
||||
dataSources?: Record<string, boolean>;
|
||||
/** '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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" } },
|
||||
|
|
|
|||
Loading…
Reference in New Issue