# Clinic Registry — Functional Specifications

**Document Type**: Functional Specifications
**Version**: 1.0
**Status**: Design Approved — Implementation Hold
**Date**: 2026-04-05
**Author**: Claude Code, directed by Haewon Kam

---

## 1. 목적 (Purpose)

잠재 고객사 성형외과의 **사전 검증된 채널 마스터 DB**를 구축하여, INFINITH 분석 파이프라인의 discovery 단계를 **확률적 검색(probabilistic search)**에서 **결정적 조회(deterministic lookup)**로 전환한다.

## 2. Scope

### In-Scope
- 국내 성형외과 **잠재 고객사 100곳 (MVP)** → 500곳 확장
- 병원 마스터 정보 (이름, 도메인, 웹사이트, 개원연도, 위치)
- 사전 검증된 채널 핸들 (YouTube/Instagram/Facebook/Naver Blog/Naver Place/강남언니/TikTok)
- Human-in-the-loop 방식 초기 구축 (Perplexity Comet 활용)
- `discover-channels` Edge Function의 Registry-only 전환
- 미등록 도메인에 대한 에러 UX

### Out-of-Scope
- 미등록 병원 자동 fallback discovery (명시적으로 제외)
- 병원 자가 등록 포털 (추후 Sprint)
- 경쟁사 자동 업데이트 추적
- 실시간 채널 메트릭 수집 (별도 `collect-channel-data` Function의 책임)

## 3. 사용자 스토리 (User Stories)

### US-1: 등록된 고객사 분석
**As a** INFINITH 운영자
**I want** 등록된 병원의 URL로 분석을 요청하면
**So that** 10초 이내에 검증된 채널 기반의 정확한 분석 결과를 받는다

**Acceptance Criteria**:
- URL의 도메인이 `clinic_registry.domain`과 매칭되면 discovery 단계를 건너뛴다
- 분석 리포트에 "✓ Registry-verified" 배지가 표시된다
- 개원연도, 위치 정보가 자동 포함된다

### US-2: 미등록 병원 차단
**As a** INFINITH 운영자
**I want** 등록되지 않은 병원 URL 입력 시 명확한 에러를 받고
**So that** 검색 오류가 있는 부정확한 분석을 방지한다

**Acceptance Criteria**:
- 미등록 도메인 입력 시 `404 CLINIC_NOT_REGISTERED` 응답
- 사용자에게 "지원하지 않는 병원" 안내 + "등록 요청" CTA 표시
- 에러는 analytics에 기록되어 향후 등록 우선순위에 반영

### US-3: Registry 데이터 큐레이션
**As a** INFINITH 운영자
**I want** Perplexity Comet으로 병원 정보를 수집하고 CSV로 import하여
**So that** 100곳의 검증된 마스터 데이터를 30–60분 내에 구축한다

**Acceptance Criteria**:
- 제공된 CSV 템플릿에 맞는 데이터를 입력할 수 있다
- import 스크립트가 데이터 검증 (필수 필드, 중복 domain 체크) 후 DB 적재
- Import 결과 요약: 성공/실패/경고 카운트

## 4. 데이터 모델 (Data Model)

### 4.1 Table: `clinic_registry`

```sql
CREATE TABLE clinic_registry (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  -- 식별 정보
  name            text NOT NULL,
  name_aliases    text[] DEFAULT '{}',
  domain          text UNIQUE NOT NULL,
  website_url     text NOT NULL,
  -- 병원 메타
  founded_year    int,
  location_gu     text,
  category        text DEFAULT '성형외과',
  -- 채널 핸들 (사전 검증)
  youtube_handle       text,
  youtube_channel_id   text,
  instagram_handle     text,
  facebook_handle      text,
  facebook_url         text,
  naver_blog_handle    text,
  naver_place_id       text,
  gangnam_unni_url     text,
  tiktok_handle        text,
  -- 큐레이션 메타
  verified_by          text NOT NULL CHECK (verified_by IN ('manual','llm','scrape')),
  verified_at          timestamptz DEFAULT now(),
  last_checked_at      timestamptz DEFAULT now(),
  is_active            boolean DEFAULT true,
  notes                text
);

CREATE UNIQUE INDEX idx_clinic_registry_domain ON clinic_registry (domain) WHERE is_active = true;
CREATE INDEX idx_clinic_registry_aliases ON clinic_registry USING gin (name_aliases);
CREATE INDEX idx_clinic_registry_last_checked ON clinic_registry (last_checked_at);
```

### 4.2 Field Dictionary

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | text | ✓ | 공식 병원명 (e.g., "아이디병원") |
| `name_aliases` | text[] | | 별칭/영문명 (e.g., `{"아이디성형외과","ID Hospital"}`) |
| `domain` | text | ✓ | `www.` 제거된 도메인. **Lookup key**. |
| `website_url` | text | ✓ | 전체 URL (redirect 대응) |
| `founded_year` | int | | 개원연도 (4-digit) |
| `location_gu` | text | | "강남구", "서초구" 등 |
| `youtube_channel_id` | text | | `UC...` 형식 (24자) |
| `naver_place_id` | text | | Naver Place 고유 ID (동명이인 방지) |
| `verified_by` | enum | ✓ | `manual` / `llm` / `scrape` |
| `is_active` | boolean | | false면 lookup에서 제외 |

## 5. 기능 요구사항 (Functional Requirements)

### FR-1: Registry Lookup
- **Input**: `url` (string, full URL)
- **Process**:
  1. `new URL(url).hostname` 추출
  2. `^www\.` prefix 제거
  3. `clinic_registry` 테이블에서 `domain = {hostname} AND is_active = true` 조회
- **Output (match)**: `{ clinicName, verifiedChannels, source: 'registry', foundedYear, location }`
- **Output (miss)**: `{ error: 'CLINIC_NOT_REGISTERED', domain, status: 404 }`
- **Latency**: <100ms (single indexed query)

### FR-2: VerifiedChannels 매핑
`clinic_registry` 레코드를 기존 `VerifiedChannels` 타입으로 변환:

```typescript
function mapRegistryToVerifiedChannels(r: ClinicRegistry): VerifiedChannels {
  return {
    youtube: r.youtube_channel_id ? {
      handle: r.youtube_handle, channelId: r.youtube_channel_id,
      verified: true, url: `https://youtube.com/${r.youtube_handle}`
    } : null,
    instagram: r.instagram_handle ? [{
      handle: r.instagram_handle, verified: true,
      url: `https://instagram.com/${r.instagram_handle}`
    }] : [],
    facebook: r.facebook_handle ? {
      handle: r.facebook_handle, verified: true,
      url: r.facebook_url ?? `https://facebook.com/${r.facebook_handle}`
    } : null,
    naverBlog: r.naver_blog_handle ? {
      handle: r.naver_blog_handle, verified: true,
      url: `https://blog.naver.com/${r.naver_blog_handle}`
    } : null,
    gangnamUnni: r.gangnam_unni_url ? { handle: '', url: r.gangnam_unni_url, verified: true } : null,
    tiktok: r.tiktok_handle ? {
      handle: r.tiktok_handle, verified: true,
      url: `https://tiktok.com/@${r.tiktok_handle}`
    } : null,
  };
}
```

### FR-3: CSV Import
- **Input**: CSV 파일 (컬럼: FR-1 field dictionary 참조)
- **Validation**:
  - `name`, `domain`, `website_url`, `verified_by` 필수
  - `domain` 중복 검사 (upsert on conflict)
  - `youtube_channel_id` 형식 체크 (`^UC[a-zA-Z0-9_-]{22}$`)
  - `founded_year` 범위 체크 (1950–현재)
- **Output**: `{ inserted, updated, skipped, errors: [] }`

### FR-4: 미등록 병원 UX
- `AnalysisLoadingPage.tsx`에서 `CLINIC_NOT_REGISTERED` 응답 감지
- 표시: 전용 페이지 (로딩 스피너 대신)
  - 헤드라인: "현재 지원하지 않는 병원입니다"
  - 본문: "{domain}은(는) 분석 대상 병원 목록에 포함되지 않습니다."
  - CTA: "병원 등록 요청하기" 버튼 → Slack 알림 또는 간단한 폼

### FR-5: Registry-verified 배지
- `ClinicSnapshot.tsx`에서 `source === 'registry'`일 때
  - 녹색 "✓ 검증된 병원" 배지
  - 개원연도 + 위치 표시 (`founded_year` + `location_gu`)

## 6. 비기능 요구사항 (Non-Functional Requirements)

| 항목 | 목표 | 근거 |
|------|------|------|
| Registry lookup 레이턴시 | <100ms | 인덱스된 단일 쿼리 |
| Registry 정확도 | 100% | Human-in-the-loop 큐레이션 |
| Registry 커버리지 (MVP) | 100곳 | 잠재 고객사 TOP 100 |
| CSV import 처리량 | 500행/30초 | 1회성 bulk insert |
| 재검증 주기 | 30일 | `last_checked_at` 기반 알림 |

## 7. API 계약 (API Contract)

### POST `/functions/v1/discover-channels` (변경됨)

**Request**:
```json
{ "url": "https://www.idhospital.com", "clinicName": "아이디병원" }
```

**Response 200 (registry hit)**:
```json
{
  "clinicName": "아이디병원",
  "verifiedChannels": { "youtube": {...}, "instagram": [...], ... },
  "source": "registry",
  "foundedYear": 2005,
  "location": "강남구"
}
```

**Response 404 (registry miss)**:
```json
{
  "error": "CLINIC_NOT_REGISTERED",
  "message": "현재 지원하지 않는 병원입니다.",
  "domain": "unknown-clinic.com"
}
```

**Breaking Changes**:
- 기존 5개 외부 API 호출 전부 제거
- `source` 필드 추가 (항상 `"registry"` 또는 에러)
- 미등록 도메인은 404 반환 (이전에는 부정확한 결과라도 200 반환)

## 8. 마이그레이션 전략 (Migration Strategy)

### Phase 0: 문서화 (완료)
- Plan / Functional Specs / Retrospective ✓

### Phase 1: Schema & Template (30min)
1. `supabase/migrations/20260405_clinic_registry.sql` 작성/적용
2. `data/clinic-registry-template.csv` 템플릿 배포

### Phase 2: Human-in-the-loop 수집 (사용자 직접, 30–60min)
1. 사용자가 Perplexity Comet으로 100곳 정보 수집
2. CSV 템플릿에 기입
3. 수동 QA (명백한 오류 제거)

### Phase 3: Import & Integration (1.5h)
1. `scripts/import-registry.ts` 작성 + 실행
2. `discover-channels/index.ts` Registry-only 전환
3. `AnalysisLoadingPage.tsx` 에러 UX
4. `ClinicSnapshot.tsx` verified 배지

### Phase 4: 검증 (30min)
1. 등록 도메인 테스트 (아이디병원) → registry hit + 정확한 채널
2. 미등록 도메인 테스트 → 404 + 등록 요청 UX
3. Edge Function 배포 + Vercel 배포

## 9. 검증 시나리오 (Test Scenarios)

| # | 시나리오 | Expected |
|---|----------|----------|
| T1 | 등록된 `idhospital.com`으로 분석 | `source: registry`, verified 배지, <100ms discovery |
| T2 | 등록된 `www.banobagi.com`으로 분석 (`www.` 처리) | Registry hit |
| T3 | 미등록 `random-clinic.com`으로 분석 | 404 + 등록 요청 UX |
| T4 | `is_active=false`인 병원 분석 | 404 (폐업 병원 차단) |
| T5 | 잘못된 CSV (중복 domain) import | Error 반환, insert 차단 |
| T6 | Registry 레코드의 Instagram 핸들 수정 후 재분석 | 수정된 핸들로 즉시 분석 |

## 10. 의존성 & 리스크 (Dependencies & Risks)

### Dependencies
- Supabase PostgreSQL (기존 인프라)
- Perplexity Comet (사용자가 수집에 사용)
- 기존 `collect-channel-data` Function (변경 없음)

### Risks
| Risk | Mitigation |
|------|-----------|
| 병원 폐업/리브랜딩 추적 실패 | `last_checked_at` 30일 기반 알림, `is_active` 플래그 |
| 채널 핸들 변경 (병원이 Instagram ID 교체) | 수동 업데이트 + 분석 시 404 응답 패턴 모니터링 |
| 고객사 확장 시 curation 병목 | Admin UI 추후 개발 (Sprint 6) |
| 미등록 URL 입력이 많아 UX 저하 | 등록 요청 CTA + 운영자 알림으로 scope 확장 |

## 11. Open Questions

1. 미등록 병원의 "등록 요청" CTA는 어떤 채널로 알림을 보낼지? (Slack / email / DB)
2. Registry 수정 권한 관리는 어떻게? (현재는 직접 SQL, 추후 Admin UI)
3. 경쟁사(non-고객사)를 비교 분석용으로 registry에 추가할 것인지?

---

*This document specifies the Registry-first architecture that replaces probabilistic channel discovery with deterministic lookup over a curated customer scope.*
