# backend/app/routers/connectors.py — phase-13 외부 연동 API # prefix 없음. main.py 에서 include_router(prefix="/api"). import uuid from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile from fastapi.responses import RedirectResponse from sqlmodel import Session, select from ..config import get_settings from ..connectors import oauth as oauthlib from ..connectors.registry import ConnectorRegistry from ..db import engine, get_session from ..models import ConnectorAccount, ConnectorAccountLog, ConnState, OAuthState from ..schemas import ( ConnectorProviderOut, ConnectorStatusOut, ImportResultOut, OAuthStartOut, SyncResultOut, ) router = APIRouter() # phase-16: '계정 추가'가 보여줄 OAuth provider 카탈로그. # (domain, provider, label, settings client_id 필드명) _OAUTH_PROVIDERS = [ ("mail", "gmail", "Gmail", "google_client_id"), ("mail", "outlook", "Outlook", "microsoft_client_id"), ("calendar", "google_calendar", "Google 캘린더", "google_client_id"), ("calendar", "outlook_calendar", "Outlook 캘린더", "microsoft_client_id"), ] @router.get("/connectors", response_model=list[ConnectorStatusOut]) def list_connectors(domain: str | None = None, s: Session = Depends(get_session)): rows = ConnectorRegistry.accounts(s, domain) return [ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain)) for a in rows] @router.get("/connectors/providers", response_model=list[ConnectorProviderOut]) def list_providers(domain: str | None = None): """OAuth로 추가 가능한 provider 목록 + 구성 여부(client_id 설정 시 configured=true).""" cfg = get_settings() out = [] for dom, provider, label, field in _OAUTH_PROVIDERS: if domain and dom != domain: continue out.append( ConnectorProviderOut( domain=dom, provider=provider, label=label, configured=bool(getattr(cfg, field, "")), ) ) return out @router.post("/connectors/{account_id}/sync", response_model=SyncResultOut) def sync_one(account_id: str, full: bool = False, s: Session = Depends(get_session)): a = s.get(ConnectorAccount, account_id) if not a: raise HTTPException(404, "connector account not found") res = ConnectorRegistry.get(s, a).sync(s, full=full) return SyncResultOut(**res.__dict__) @router.post("/connectors/sync-all", response_model=list[SyncResultOut]) def sync_all(s: Session = Depends(get_session)): out = [] for a in ConnectorRegistry.accounts(s): if a.state == ConnState.disconnected: # 연결 안 된 계정은 건너뜀 continue out.append(SyncResultOut(**ConnectorRegistry.get(s, a).sync(s).__dict__)) return out # ── OAuth ── def _with_query(path: str, extra: str) -> str: """redirect_after 경로에 쿼리 안전 append('/settings?tab=mail' + 'connect=ok').""" sep = "&" if "?" in path else "?" return f"{path}{sep}{extra}" @router.get("/connectors/oauth/start", response_model=OAuthStartOut) def oauth_start( domain: str, provider: str, redirect_after: str = "/life", s: Session = Depends(get_session) ): cfg = get_settings() if provider in ("gmail", "google_calendar") and not cfg.google_client_id: raise HTTPException(400, "google_client_id 미설정 — real 연동 비활성") if provider in ("outlook", "outlook_calendar") and not cfg.microsoft_client_id: raise HTTPException(400, "microsoft_client_id 미설정 — real 연동 비활성") if provider == "notion" and not cfg.notion_client_id: raise HTTPException(400, "notion_client_id 미설정 — real 연동 비활성") url = oauthlib.start_oauth(s, domain, provider, redirect_after) return OAuthStartOut(authorize_url=url) def _initial_sync(account_id: str) -> None: """연결 직후 초기 full sync — 백그라운드 실행(대량 메일도 콜백 응답을 막지 않음).""" with Session(engine) as bs: acct = bs.get(ConnectorAccount, account_id) if not acct: return targets = [acct] # 메일과 함께 연결된 캘린더 계정(같은 이메일)도 초기 sync. sibling_provider = {"gmail": "google_calendar", "outlook": "outlook_calendar"}.get( acct.provider ) if sibling_provider and acct.external_account_id: sib = bs.exec( select(ConnectorAccount).where( ConnectorAccount.provider == sibling_provider, ConnectorAccount.external_account_id == acct.external_account_id, ) ).first() if sib: targets.append(sib) for a in targets: try: ConnectorRegistry.get(bs, a).sync(bs, full=True) except Exception: pass @router.get("/connectors/oauth/callback") def oauth_callback( code: str, state: str, bg: BackgroundTasks, s: Session = Depends(get_session) ): origin = get_settings().frontend_origin os_row = s.get(OAuthState, state) redirect_after = os_row.redirect_after if os_row else "/life" try: acct = oauthlib.finish_oauth(s, code, state) except Exception: return RedirectResponse(url=f"{origin}{_with_query(redirect_after, 'connect=error')}") # 초기 sync 는 백그라운드로(받은편지함이 크면 수십 초 소요 → 콜백은 즉시 리다이렉트). bg.add_task(_initial_sync, acct.id) q = f"connect=ok&domain={acct.domain.value}&provider={acct.provider}" return RedirectResponse(url=f"{origin}{_with_query(redirect_after, q)}") @router.post("/connectors/{account_id}/disconnect", response_model=ConnectorStatusOut) def disconnect(account_id: str, s: Session = Depends(get_session)): a = s.get(ConnectorAccount, account_id) if not a: raise HTTPException(404, "not found") a.state = ConnState.disconnected a.token_enc = "" a.last_label = "연결 안 됨" s.add(a) s.add( ConnectorAccountLog( id="cl-" + uuid.uuid4().hex[:8], account_id=a.id, action="disconnected", detail="사용자 해제", ) ) s.commit() return ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain)) # ── 로컬 우선: 수동 임포트(CSV / .ics / HealthKit export) ── @router.post("/connectors/{account_id}/import", response_model=ImportResultOut) async def import_file( account_id: str, file: UploadFile = File(...), s: Session = Depends(get_session) ): a = s.get(ConnectorAccount, account_id) if not a: raise HTTPException(404, "not found") conn = ConnectorRegistry.get(s, a) if not hasattr(conn, "import_bytes"): raise HTTPException(400, "이 커넥터는 파일 임포트를 지원하지 않아요") content = await file.read() res = conn.import_bytes(s, content, filename=file.filename or "") return ImportResultOut(**res)