# backend/app/routers/connectors.py — phase-13 외부 연동 API # prefix 없음. main.py 에서 include_router(prefix="/api"). import uuid from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import RedirectResponse from sqlmodel import Session from ..config import get_settings from ..connectors import oauth as oauthlib from ..connectors.registry import ConnectorRegistry from ..db import get_session from ..models import ConnectorAccount, ConnectorAccountLog, ConnState from ..schemas import ConnectorStatusOut, ImportResultOut, OAuthStartOut, SyncResultOut router = APIRouter() @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.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 ── @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 == "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) @router.get("/connectors/oauth/callback") def oauth_callback(code: str, state: str, s: Session = Depends(get_session)): origin = get_settings().frontend_origin try: acct = oauthlib.finish_oauth(s, code, state) except Exception: return RedirectResponse(url=f"{origin}/life?connect=error") try: ConnectorRegistry.get(s, acct).sync(s, full=True) # 연결 직후 1회 초기 sync except Exception: pass return RedirectResponse(url=f"{origin}/life?connect=ok&domain={acct.domain.value}") @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)