From e011ef7357e094db0e6b70fca7b5a79881d1bab3 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Sun, 5 Apr 2026 00:45:34 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20DB=20Schema=20V3=20=E2=80=94=20SaaS=20m?= =?UTF-8?q?ulti-tenant=20with=20time-series=20+=20performance=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9 tables: clinics, analysis_runs, channel_snapshots, screenshots, content_plans, channel_configs, performance_metrics, content_performance, strategy_adjustments 2 views: channel_latest, channel_weekly_delta Key features: - Clinic-centric (1 hospital = 1 row, multiple analyses) - Time-series channel metrics (INSERT-only snapshots) - Performance → Strategy loop (weekly KPI tracking → auto-adjust plans) - Content performance tracking (individual post metrics) - 8-phase implementation checklist with verification criteria Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/DB_SCHEMA_V3.md | 262 ++++++++++++ .../migrations/20260405_saas_schema_v3.sql | 374 ++++++++++++++++++ 2 files changed, 636 insertions(+) create mode 100644 doc/DB_SCHEMA_V3.md create mode 100644 supabase/migrations/20260405_saas_schema_v3.sql diff --git a/doc/DB_SCHEMA_V3.md b/doc/DB_SCHEMA_V3.md new file mode 100644 index 0000000..c449f32 --- /dev/null +++ b/doc/DB_SCHEMA_V3.md @@ -0,0 +1,262 @@ +# INFINITH SaaS Database Schema V3 + +**작성일**: 2026-04-05 +**마이그레이션 파일**: `supabase/migrations/20260405_saas_schema_v3.sql` + +## 설계 원칙 + +1. **CLINIC-CENTRIC**: 병원 1개 = 1행. URL이 달라도 같은 병원이면 같은 행 +2. **TIME-SERIES**: 채널 메트릭은 INSERT-only 스냅샷 (시계열 쿼리) +3. **SEPARATION**: 원시 데이터 / 분석 리포트 / 콘텐츠 전략 분리 +4. **LOOP-READY**: 매 분석이 이전 데이터를 참조해 전략 자동 조정 +5. **MULTI-TENANT**: user_id 기반 접근 제어 (미래 auth) + +--- + +## ERD (Entity Relationship) + +``` +clinics (병원 마스터) + ├─ analysis_runs (분석 실행 히스토리) ← 매주 1행씩 쌓임 + │ ├─ channel_snapshots (채널별 시계열 메트릭) ← INSERT-only + │ ├─ screenshots (스크린샷 증거) + │ └─ performance_metrics (성과 메트릭) + │ └─ strategy_adjustments (전략 조정 근거) + ├─ channel_configs (사용자 수동 채널 연결) + ├─ content_plans (콘텐츠 기획 — 활성 1개) + │ └─ content_performance (개별 콘텐츠 성과) + └─ (marketing_reports) ← 레거시 호환 +``` + +--- + +## 테이블 상세 + +### 1. `clinics` — 병원 마스터 +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | UUID PK | | +| user_id | UUID | 소유자 (미래 auth) | +| url | TEXT UNIQUE | 대표 URL | +| name | TEXT | 한국어 병원명 | +| name_en | TEXT | 영문 병원명 | +| domain | TEXT | 도메인 | +| address, phone | TEXT | 기본 정보 | +| established_year | INT | 개원 연도 | +| services | TEXT[] | 시술 목록 | +| branding | JSONB | 컬러, 폰트, 로고, 태그라인 | +| social_handles | JSONB | 검증된 소셜 핸들 | +| verified_channels | JSONB | Phase 1 결과 캐시 | +| analysis_frequency | TEXT | 'manual' / 'daily' / 'weekly' / 'monthly' | +| last_analyzed_at | TIMESTAMPTZ | 마지막 분석 시간 | + +### 2. `analysis_runs` — 분석 실행 히스토리 +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | UUID PK | | +| clinic_id | UUID FK → clinics | | +| status | TEXT | pending/discovering/collecting/generating/complete/partial/error | +| scrape_data | JSONB | Firecrawl 원시 데이터 | +| raw_channel_data | JSONB | API 수집 원시 데이터 | +| analysis_data | JSONB | 시장 분석 | +| vision_analysis | JSONB | Vision 분석 결과 | +| report | JSONB | AI 리포트 | +| channel_errors | JSONB | 채널별 에러 기록 | +| trigger | TEXT | 'manual' / 'scheduled' / 'webhook' | + +### 3. `channel_snapshots` — 채널별 시계열 ⭐ 핵심 +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | UUID PK | | +| clinic_id | UUID FK → clinics | | +| run_id | UUID FK → analysis_runs | | +| channel | TEXT | youtube/instagram/facebook/gangnamUnni/... | +| handle | TEXT | @handle 또는 URL | +| followers | INT | 구독자/팔로워 | +| posts | INT | 게시물/영상 수 | +| total_views | BIGINT | 총 조회수 | +| rating | NUMERIC(3,1) | 평점 | +| reviews | INT | 리뷰 수 | +| health_score | INT | 0-100 | +| details | JSONB | 상세 (top videos, latest posts 등) | +| screenshot_url | TEXT | 채널 랜딩 스크린샷 | +| captured_at | TIMESTAMPTZ | 캡처 시간 | + +**핵심**: INSERT-only. 절대 UPDATE 하지 않음. `captured_at` 기준 시계열 쿼리. + +### 4. `screenshots` — 스크린샷 증거 +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | UUID PK | | +| clinic_id, run_id | UUID FK | | +| channel | TEXT | 어떤 채널 | +| page_type | TEXT | main/doctors/surgery/landing | +| url | TEXT | 이미지 URL (Supabase Storage) | +| source_url | TEXT | 원본 페이지 URL | +| vision_data | JSONB | Gemini Vision 추출 데이터 | + +### 5. `content_plans` — 콘텐츠 기획 +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | UUID PK | | +| clinic_id | UUID FK → clinics | | +| run_id | UUID FK → analysis_runs | 어떤 분석 기반 | +| brand_guide | JSONB | 브랜딩 가이드 | +| channel_strategies | JSONB | 채널별 전략 | +| content_strategy | JSONB | 필라, 타입, 워크플로우 | +| calendar | JSONB | 4주 캘린더 | +| is_active | BOOLEAN | 현재 활성 기획 | + +### 6. `channel_configs` — 사용자 수동 채널 연결 +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | UUID PK | | +| clinic_id | UUID FK | | +| channel | TEXT | 채널 종류 | +| handle | TEXT | @handle | +| is_verified | BOOLEAN | 사용자가 직접 확인 | +| access_token | TEXT | OAuth token (미래) | + +### 7. `performance_metrics` — 성과 메트릭 (루프 핵심) +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | UUID PK | | +| clinic_id, run_id | UUID FK | | +| prev_run_id | UUID FK | 이전 분석 (비교 기준) | +| channel_deltas | JSONB | 채널별 변화량 | +| kpi_progress | JSONB | KPI 달성률 | +| top_performing_content | JSONB | 성과 좋은 콘텐츠 | +| underperforming_channels | TEXT[] | 성과 미달 채널 | +| strategy_suggestions | JSONB | AI 전략 조정 제안 | + +### 8. `content_performance` — 개별 콘텐츠 성과 +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | UUID PK | | +| clinic_id | UUID FK | | +| plan_id | UUID FK | 어떤 기획의 콘텐츠 | +| channel, content_type | TEXT | 채널 + 유형 | +| views, likes, comments, shares | INT | 반응 지표 | +| engagement_rate | NUMERIC | 참여율 | +| performance_score | INT | 0-100 | + +### 9. `strategy_adjustments` — 전략 조정 히스토리 +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | UUID PK | | +| clinic_id, plan_id | UUID FK | | +| performance_id | UUID FK | 어떤 성과 분석 기반 | +| adjustment_type | TEXT | frequency_change/pillar_shift/channel_add/... | +| description | TEXT | "YouTube Shorts 주 3회 → 5회로 증가" | +| reason | TEXT | "Shorts 조회수가 Long-form 대비 300% 높음" | +| before_value, after_value | JSONB | 변경 전/후 값 | + +--- + +## Views (트렌드 쿼리용) + +### `channel_latest` +각 채널의 최신 스냅샷만 반환. `DISTINCT ON` + `ORDER BY captured_at DESC`. + +### `channel_weekly_delta` +현재 vs 7일 전 스냅샷을 `LATERAL JOIN`으로 비교. 팔로워 변화율(%) 자동 계산. + +--- + +## 성과 → 기획 루프 플로우 + +``` +Week N: + 1. analysis_run 생성 + 2. channel_snapshots INSERT (현재 메트릭) + 3. performance_metrics 생성 (이전 run과 비교) + → channel_deltas: {youtube: +2%, instagram: +7.1%} + → kpi_progress: [{metric: "YouTube 구독자", progress: 97.4%}] + → strategy_suggestions: ["Instagram Reels 빈도 증가 권장"] + 4. strategy_adjustments INSERT (조정 내역) + 5. content_plans UPDATE (조정 반영) + → Content Director가 strategy_suggestions를 기반으로 캘린더 자동 조정 + 6. report 생성 (이전 분석 대비 변화 포함) + +Week N+1: + → 2번부터 반복. channel_snapshots에 데이터가 쌓이면서 트렌드 정확도 ↑ +``` + +--- + +## 기존 호환성 + +| 기존 테이블 | 상태 | 이관 계획 | +|-----------|------|---------| +| `marketing_reports` | 유지 (deprecated) | 기존 API 호환 유지, 새 데이터는 새 테이블에만 저장 | +| `scrape_results` | 유지 | 캐시용 | + +--- + +## 구현 체크리스트 + +### Phase 1: DB 마이그레이션 (테이블 생성) + +- [ ] Supabase Dashboard에서 `20260405_saas_schema_v3.sql` 실행 +- [ ] 테이블 9개 생성 확인: clinics, analysis_runs, channel_snapshots, screenshots, content_plans, channel_configs, performance_metrics, content_performance, strategy_adjustments +- [ ] View 2개 생성 확인: channel_latest, channel_weekly_delta +- [ ] RLS 정책 적용 확인 +- [ ] 인덱스 생성 확인 + +### Phase 2: Edge Function 전환 — discover-channels + +- [ ] `discover-channels/index.ts`: `clinics` 테이블에 UPSERT (url 기준) +- [ ] `discover-channels/index.ts`: `analysis_runs` 테이블에 INSERT (status: 'discovering') +- [ ] `discover-channels/index.ts`: `clinic.verified_channels` 업데이트 +- [ ] 기존 `marketing_reports`에도 병행 쓰기 유지 (호환성) +- [ ] 검증: 분석 실행 → clinics + analysis_runs 행 생성 확인 + +### Phase 3: Edge Function 전환 — collect-channel-data + +- [ ] `collect-channel-data/index.ts`: `channel_snapshots`에 채널별 INSERT +- [ ] `collect-channel-data/index.ts`: `screenshots`에 스크린샷 INSERT +- [ ] `collect-channel-data/index.ts`: `analysis_runs.raw_channel_data` 업데이트 +- [ ] `collect-channel-data/index.ts`: `analysis_runs.vision_analysis` 업데이트 +- [ ] 기존 `marketing_reports.channel_data`에도 병행 쓰기 유지 +- [ ] 검증: channel_snapshots에 채널별 행 생성 확인 + +### Phase 4: Edge Function 전환 — generate-report + +- [ ] `generate-report/index.ts`: `analysis_runs.report` 업데이트 +- [ ] `generate-report/index.ts`: `analysis_runs.status = 'complete'` +- [ ] `generate-report/index.ts`: `clinics.last_analyzed_at` 업데이트 +- [ ] `content_plans` 자동 생성 (transformPlan 로직 서버사이드 이동) +- [ ] 기존 `marketing_reports.report`에도 병행 쓰기 유지 +- [ ] 검증: analysis_runs.status = 'complete' + report JSONB 생성 확인 + +### Phase 5: 성과 분석 루프 구현 + +- [ ] 반복 분석 시 `performance_metrics` 자동 생성 (이전 run 대비) +- [ ] `channel_weekly_delta` View 쿼리 테스트 +- [ ] `strategy_suggestions` 생성 로직 (Content Director 연동) +- [ ] `content_plans` 자동 업데이트 (전략 조정 반영) +- [ ] `strategy_adjustments` 히스토리 기록 +- [ ] 검증: 2회 연속 분석 → performance_metrics에 변화량 기록 확인 + +### Phase 6: Frontend 전환 + +- [ ] `useReport` 훅: analysis_runs + channel_snapshots에서 읽기 +- [ ] `useMarketingPlan` 훅: content_plans에서 읽기 +- [ ] ReportPage: channel_snapshots 시계열 데이터로 트렌드 차트 추가 +- [ ] KPIDashboard: performance_metrics의 달성률 표시 +- [ ] 검증: 기존 리포트 URL이 새 테이블에서도 정상 렌더링 + +### Phase 7: 스케줄링 + +- [ ] `clinics.analysis_frequency` 기반 자동 분석 트리거 +- [ ] Supabase Cron 또는 external scheduler 연동 +- [ ] `analysis_runs.trigger = 'scheduled'` 기록 +- [ ] 검증: 주간 자동 분석 실행 확인 + +### Phase 8: 기존 데이터 이관 + +- [ ] `marketing_reports` → `clinics` + `analysis_runs` 이관 스크립트 +- [ ] `marketing_reports.channel_data` → `channel_snapshots` 변환 +- [ ] `marketing_reports.report` → `analysis_runs.report` 복사 +- [ ] 이관 후 기존 리포트 URL 정상 작동 확인 +- [ ] `marketing_reports` deprecated 마킹 diff --git a/supabase/migrations/20260405_saas_schema_v3.sql b/supabase/migrations/20260405_saas_schema_v3.sql new file mode 100644 index 0000000..4825c38 --- /dev/null +++ b/supabase/migrations/20260405_saas_schema_v3.sql @@ -0,0 +1,374 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 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 사용 +-- 마이그레이션 스크립트로 기존 데이터를 새 테이블로 이관 가능