-- ═══════════════════════════════════════════════════════════════ -- INFINITH SaaS Schema V3: Multi-tenant, Time-series, Loop-ready -- ═══════════════════════════════════════════════════════════════ -- -- Design Principles: -- 1. CLINIC-CENTRIC: One clinic entity, many analysis runs -- 2. TIME-SERIES: Channel metrics stored as snapshots over time -- 3. SEPARATION: Raw data vs analyzed report vs strategy -- 4. LOOP-READY: Each run builds on previous data for trend analysis -- 5. MULTI-TENANT: user_id on everything for future auth -- -- Data Flow: -- clinics (병원 마스터) -- └─ analysis_runs (분석 실행 히스토리) -- ├─ channel_snapshots (채널별 시계열 데이터) -- ├─ screenshots (스크린샷 증거) -- └─ reports (AI 리포트) -- └─ channel_configs (채널 연결 설정 - 핸들, 검증 상태) -- └─ content_plans (콘텐츠 기획) -- ─── 1. Clinics (병원 마스터 테이블) ─── -- 병원 1개 = 1행. URL이 달라도 같은 병원이면 같은 행. CREATE TABLE IF NOT EXISTS clinics ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, user_id UUID, -- 나중에 auth.users.id 연결 url TEXT NOT NULL, -- 대표 URL name TEXT NOT NULL, -- 한국어 병원명 name_en TEXT, -- 영문 병원명 domain TEXT, -- 도메인 (www 제외) address TEXT, phone TEXT, established_year INT, -- 개원 연도 services TEXT[] DEFAULT '{}', -- 시술 목록 -- 브랜딩 (변경 빈도 낮음 → 병원 레벨에 저장) branding JSONB DEFAULT '{}', -- colors, fonts, logo, tagline -- 채널 핸들 (마스터 — 검증 완료된 것들) -- 매 분석 시 discover-channels에서 업데이트 social_handles JSONB DEFAULT '{}', -- {instagram: ["@handle1"], youtube: "@handle", ...} verified_channels JSONB DEFAULT '{}', -- Phase 1 결과 (캐시) -- 메타 created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), last_analyzed_at TIMESTAMPTZ, -- 마지막 분석 시간 analysis_frequency TEXT DEFAULT 'manual', -- 'manual' | 'daily' | 'weekly' | 'monthly' UNIQUE(url) ); -- ─── 2. Analysis Runs (분석 실행 히스토리) ─── -- 매 분석 1회 = 1행. 시간순으로 쌓임. CREATE TABLE IF NOT EXISTS analysis_runs ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, -- 파이프라인 상태 status TEXT DEFAULT 'pending' CHECK (status IN ('pending','discovering','collecting','generating','complete','partial','error')), pipeline_started_at TIMESTAMPTZ DEFAULT NOW(), pipeline_completed_at TIMESTAMPTZ, error_message TEXT, channel_errors JSONB DEFAULT '{}', -- 채널별 에러 기록 -- Phase 1 결과 scrape_data JSONB DEFAULT '{}', -- Firecrawl 원시 데이터 discovered_channels JSONB DEFAULT '{}', -- 발견된 채널 목록 -- Phase 2 결과 (원시 데이터 — 정규화 전) raw_channel_data JSONB DEFAULT '{}', -- 모든 API 수집 원시 데이터 analysis_data JSONB DEFAULT '{}', -- 시장 분석 (Perplexity) vision_analysis JSONB DEFAULT '{}', -- Vision 분석 결과 -- Phase 3 결과 report JSONB DEFAULT '{}', -- AI 리포트 (최종) -- 메타 trigger TEXT DEFAULT 'manual', -- 'manual' | 'scheduled' | 'webhook' created_at TIMESTAMPTZ DEFAULT NOW() ); -- ─── 3. Channel Snapshots (채널별 시계열 데이터) ─── -- 각 채널의 메트릭을 시간별로 쌓음. 트렌드 분석의 핵심. -- 매 분석 시 채널별 1행씩 INSERT (UPDATE 아님!) CREATE TABLE IF NOT EXISTS channel_snapshots ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, run_id UUID NOT NULL REFERENCES analysis_runs(id) ON DELETE CASCADE, channel TEXT NOT NULL, -- 'youtube' | 'instagram' | 'facebook' | 'gangnamUnni' | 'naverBlog' | 'naverPlace' | 'googleMaps' | 'website' handle TEXT, -- @handle 또는 URL -- 정량 메트릭 (채널별로 다르지만 공통 컬럼으로) followers INT, -- YouTube 구독자, Instagram 팔로워, Facebook 팔로워 posts INT, -- 게시물 수, 영상 수 total_views BIGINT, -- YouTube 총 조회수 rating NUMERIC(3,1), -- 강남언니, Google Maps, Naver Place 평점 rating_scale INT DEFAULT 10, -- 5 또는 10 reviews INT, -- 리뷰 수 -- 채널별 상세 데이터 (JSONB) details JSONB DEFAULT '{}', -- top videos, latest posts, doctors, etc. -- AI 점수 health_score INT, -- 0-100 채널 건강도 점수 health_status TEXT, -- 'excellent' | 'good' | 'warning' | 'critical' diagnosis JSONB DEFAULT '[]', -- [{issue, severity, recommendation}] -- 스크린샷 screenshot_url TEXT, -- Supabase Storage URL screenshot_caption TEXT, captured_at TIMESTAMPTZ DEFAULT NOW() ); -- ─── 4. Screenshots (스크린샷 증거) ─── CREATE TABLE IF NOT EXISTS screenshots ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, run_id UUID NOT NULL REFERENCES analysis_runs(id) ON DELETE CASCADE, channel TEXT NOT NULL, -- 어떤 채널의 스크린샷인지 page_type TEXT, -- 'main' | 'doctors' | 'surgery' | 'landing' url TEXT NOT NULL, -- 스크린샷 이미지 URL (Supabase Storage) source_url TEXT, -- 원본 페이지 URL caption TEXT, -- Vision 분석 결과 vision_data JSONB DEFAULT '{}', -- Gemini Vision 추출 데이터 captured_at TIMESTAMPTZ DEFAULT NOW() ); -- ─── 5. Content Plans (콘텐츠 기획) ─── -- 분석 결과를 기반으로 생성된 콘텐츠 기획. 매 분석마다 갱신 가능. CREATE TABLE IF NOT EXISTS content_plans ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, run_id UUID REFERENCES analysis_runs(id), -- 어떤 분석 기반인지 -- 기획 데이터 brand_guide JSONB DEFAULT '{}', -- 브랜딩 가이드 channel_strategies JSONB DEFAULT '[]', -- 채널별 전략 카드 content_strategy JSONB DEFAULT '{}', -- 콘텐츠 필라, 타입 매트릭스, 워크플로우 calendar JSONB DEFAULT '{}', -- 4주 캘린더 asset_collection JSONB DEFAULT '{}', -- 에셋 수집 현황 -- 메타 is_active BOOLEAN DEFAULT TRUE, -- 현재 활성 기획인지 created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- ─── 6. Channel Configs (채널 연결 설정) ─── -- 사용자가 직접 연결하거나 수정한 채널 정보. discover가 자동 탐지한 것과 별도. CREATE TABLE IF NOT EXISTS channel_configs ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, channel TEXT NOT NULL, -- 'youtube' | 'instagram' | etc. handle TEXT NOT NULL, -- @handle 또는 URL is_verified BOOLEAN DEFAULT FALSE, -- 사용자가 직접 확인한 경우 is_active BOOLEAN DEFAULT TRUE, -- 활성/비활성 -- 연결 인증 (미래 — API 직접 연동 시) access_token TEXT, -- OAuth token (암호화 필요) token_expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(clinic_id, channel, handle) ); -- ─── 7. Performance Metrics (성과 메트릭 — 주간 루프) ─── -- 각 분석 시 이전 분석과 비교하여 KPI 달성률을 계산. -- Content Director가 이 데이터를 참조하여 다음 기획을 조정. CREATE TABLE IF NOT EXISTS performance_metrics ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, run_id UUID NOT NULL REFERENCES analysis_runs(id) ON DELETE CASCADE, prev_run_id UUID REFERENCES analysis_runs(id), -- 이전 분석 (비교 기준) -- 주간 변화 요약 period_start TIMESTAMPTZ, -- 측정 기간 시작 period_end TIMESTAMPTZ, -- 측정 기간 끝 -- 채널별 성과 channel_deltas JSONB DEFAULT '{}', -- {youtube: {followers: +500, pct: 3.8}, instagram: {...}} -- KPI 달성률 kpi_progress JSONB DEFAULT '[]', -- [{metric: "YouTube 구독자", target: 115000, current: 112000, progress_pct: 97.4}] -- 콘텐츠 성과 요약 top_performing_content JSONB DEFAULT '[]', -- [{title, channel, views, engagement}] underperforming_channels TEXT[], -- 성과 미달 채널 목록 -- AI 전략 조정 제안 strategy_suggestions JSONB DEFAULT '[]', -- [{channel, action: "increase_frequency", reason: "팔로워 증가율 둔화"}] created_at TIMESTAMPTZ DEFAULT NOW() ); -- ─── 8. Content Performance (개별 콘텐츠 성과 추적) ─── -- 배포된 콘텐츠의 실제 성과를 추적. 다음 기획에 반영. CREATE TABLE IF NOT EXISTS content_performance ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, plan_id UUID REFERENCES content_plans(id), channel TEXT NOT NULL, -- 게시된 채널 content_type TEXT, -- 'video' | 'blog' | 'social' | 'ad' title TEXT, -- 콘텐츠 제목 published_at TIMESTAMPTZ, -- 게시일 url TEXT, -- 콘텐츠 URL -- 성과 메트릭 views INT DEFAULT 0, likes INT DEFAULT 0, comments INT DEFAULT 0, shares INT DEFAULT 0, engagement_rate NUMERIC(5,2), -- (likes+comments+shares)/views * 100 -- 전환 (추적 가능한 경우) clicks INT DEFAULT 0, conversions INT DEFAULT 0, -- 상담 예약 등 -- AI 평가 performance_score INT, -- 0-100 performance_label TEXT, -- 'viral' | 'good' | 'average' | 'underperforming' created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- ─── 9. Strategy Adjustments (전략 조정 히스토리) ─── -- 성과 분석 결과에 따라 자동/수동으로 조정된 전략 기록. -- "왜 이 전략이 변경됐는지" 근거를 추적. CREATE TABLE IF NOT EXISTS strategy_adjustments ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, clinic_id UUID NOT NULL REFERENCES clinics(id) ON DELETE CASCADE, plan_id UUID REFERENCES content_plans(id), performance_id UUID REFERENCES performance_metrics(id), -- 어떤 성과 분석 기반인지 adjustment_type TEXT NOT NULL, -- 'frequency_change' | 'pillar_shift' | 'channel_add' | 'channel_pause' | 'content_type_change' channel TEXT, -- 대상 채널 description TEXT NOT NULL, -- "YouTube Shorts 주 3회 → 5회로 증가" reason TEXT NOT NULL, -- "Shorts 평균 조회수가 Long-form 대비 300% 높음" -- 변경 전/후 before_value JSONB, -- {frequency: "주 3회", pillar: "전문성"} after_value JSONB, -- {frequency: "주 5회", pillar: "전문성+결과"} applied_at TIMESTAMPTZ DEFAULT NOW(), applied_by TEXT DEFAULT 'system' -- 'system' | 'user' ); -- ═══════════════════════════════════════════════════════════════ -- Indexes -- ═══════════════════════════════════════════════════════════════ -- Clinic lookups CREATE INDEX IF NOT EXISTS idx_clinics_user ON clinics(user_id); CREATE INDEX IF NOT EXISTS idx_clinics_url ON clinics(url); CREATE INDEX IF NOT EXISTS idx_clinics_domain ON clinics(domain); -- Analysis run history (시간순) CREATE INDEX IF NOT EXISTS idx_analysis_runs_clinic ON analysis_runs(clinic_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_analysis_runs_status ON analysis_runs(clinic_id, status); -- Channel snapshots (시계열 쿼리 최적화) CREATE INDEX IF NOT EXISTS idx_channel_snapshots_clinic_channel ON channel_snapshots(clinic_id, channel, captured_at DESC); CREATE INDEX IF NOT EXISTS idx_channel_snapshots_run ON channel_snapshots(run_id); -- Screenshots CREATE INDEX IF NOT EXISTS idx_screenshots_clinic ON screenshots(clinic_id, run_id); -- Content plans CREATE INDEX IF NOT EXISTS idx_content_plans_clinic ON content_plans(clinic_id, is_active); -- Performance metrics (성과 분석 시계열) CREATE INDEX IF NOT EXISTS idx_performance_metrics_clinic ON performance_metrics(clinic_id, created_at DESC); -- Content performance CREATE INDEX IF NOT EXISTS idx_content_performance_clinic ON content_performance(clinic_id, published_at DESC); CREATE INDEX IF NOT EXISTS idx_content_performance_channel ON content_performance(clinic_id, channel, published_at DESC); -- Strategy adjustments CREATE INDEX IF NOT EXISTS idx_strategy_adjustments_clinic ON strategy_adjustments(clinic_id, applied_at DESC); -- ═══════════════════════════════════════════════════════════════ -- RLS Policies -- ═══════════════════════════════════════════════════════════════ ALTER TABLE clinics ENABLE ROW LEVEL SECURITY; ALTER TABLE analysis_runs ENABLE ROW LEVEL SECURITY; ALTER TABLE channel_snapshots ENABLE ROW LEVEL SECURITY; ALTER TABLE screenshots ENABLE ROW LEVEL SECURITY; ALTER TABLE content_plans ENABLE ROW LEVEL SECURITY; ALTER TABLE channel_configs ENABLE ROW LEVEL SECURITY; -- Service role (Edge Functions) — full access CREATE POLICY "service_all_clinics" ON clinics FOR ALL USING (auth.role() = 'service_role'); CREATE POLICY "service_all_runs" ON analysis_runs FOR ALL USING (auth.role() = 'service_role'); CREATE POLICY "service_all_snapshots" ON channel_snapshots FOR ALL USING (auth.role() = 'service_role'); CREATE POLICY "service_all_screenshots" ON screenshots FOR ALL USING (auth.role() = 'service_role'); CREATE POLICY "service_all_plans" ON content_plans FOR ALL USING (auth.role() = 'service_role'); CREATE POLICY "service_all_configs" ON channel_configs FOR ALL USING (auth.role() = 'service_role'); CREATE POLICY "service_all_performance" ON performance_metrics FOR ALL USING (auth.role() = 'service_role'); CREATE POLICY "service_all_content_perf" ON content_performance FOR ALL USING (auth.role() = 'service_role'); CREATE POLICY "service_all_adjustments" ON strategy_adjustments FOR ALL USING (auth.role() = 'service_role'); ALTER TABLE performance_metrics ENABLE ROW LEVEL SECURITY; ALTER TABLE content_performance ENABLE ROW LEVEL SECURITY; ALTER TABLE strategy_adjustments ENABLE ROW LEVEL SECURITY; -- Anon read (demo mode — 나중에 user_id 기반으로 변경) CREATE POLICY "anon_read_clinics" ON clinics FOR SELECT USING (true); CREATE POLICY "anon_read_runs" ON analysis_runs FOR SELECT USING (true); CREATE POLICY "anon_read_snapshots" ON channel_snapshots FOR SELECT USING (true); CREATE POLICY "anon_read_screenshots" ON screenshots FOR SELECT USING (true); CREATE POLICY "anon_read_plans" ON content_plans FOR SELECT USING (true); CREATE POLICY "anon_read_performance" ON performance_metrics FOR SELECT USING (true); CREATE POLICY "anon_read_content_perf" ON content_performance FOR SELECT USING (true); CREATE POLICY "anon_read_adjustments" ON strategy_adjustments FOR SELECT USING (true); -- ═══════════════════════════════════════════════════════════════ -- Utility Views (트렌드 분석용) -- ═══════════════════════════════════════════════════════════════ -- 채널별 최신 스냅샷 (현재 상태) CREATE OR REPLACE VIEW channel_latest AS SELECT DISTINCT ON (clinic_id, channel) clinic_id, channel, handle, followers, posts, total_views, rating, rating_scale, reviews, health_score, health_status, screenshot_url, captured_at FROM channel_snapshots ORDER BY clinic_id, channel, captured_at DESC; -- 채널별 주간 변화량 CREATE OR REPLACE VIEW channel_weekly_delta AS SELECT curr.clinic_id, curr.channel, curr.followers AS current_followers, prev.followers AS prev_followers, CASE WHEN prev.followers > 0 THEN ROUND((curr.followers - prev.followers)::NUMERIC / prev.followers * 100, 1) ELSE NULL END AS followers_change_pct, curr.reviews AS current_reviews, prev.reviews AS prev_reviews, curr.captured_at FROM channel_latest curr LEFT JOIN LATERAL ( SELECT followers, reviews FROM channel_snapshots cs WHERE cs.clinic_id = curr.clinic_id AND cs.channel = curr.channel AND cs.captured_at < curr.captured_at - INTERVAL '6 days' ORDER BY cs.captured_at DESC LIMIT 1 ) prev ON true; -- ═══════════════════════════════════════════════════════════════ -- Backward Compatibility -- ═══════════════════════════════════════════════════════════════ -- marketing_reports 테이블은 유지 (기존 데이터 + 레거시 API 호환) -- 새 파이프라인은 clinics + analysis_runs + channel_snapshots 사용 -- 마이그레이션 스크립트로 기존 데이터를 새 테이블로 이관 가능