"""B2B API routes: external clients analyze contracts via `X-API-Key`. Management endpoints (create/revoke/list keys) use service-token auth because they are called by internal adapters (web/cli) on behalf of a user identified by `telegram_id`. Analysis endpoints use `X-API-Key` auth only. """ from __future__ import annotations import uuid from typing import Annotated from fastapi import APIRouter, File, HTTPException, UploadFile, status from src.contract_check.api.deps import ( ApiKeyAuthDep, AsyncSessionDep, CurrentUserDep, PublisherDep, StorageDep, fetch_document_status_for_user, ) from src.contract_check.api.schemas import ( ApiKeyCreatedResponse, ApiKeyResponse, ApiKeyUsageDetailResponse, B2BUsageResponse, CreateApiKeyRequest, DocumentUploadResponse, ReportInProgressResponse, ReportResponse, RevokeApiKeyResponse, UserProfile, ) from src.contract_check.api.services import upload_and_enqueue from src.contract_check.core.api_keys import generate_api_key, hash_api_key from src.contract_check.core.db.repositories import ApiKeyRepository, UserProfileRepository from src.contract_check.core.logging import get_logger log = get_logger(__name__) router = APIRouter(tags=["b2b"]) @router.post( "/api/v1/analyze", response_model=DocumentUploadResponse, status_code=status.HTTP_202_ACCEPTED ) async def analyze_document( auth: ApiKeyAuthDep, session: AsyncSessionDep, storage: StorageDep, publisher: PublisherDep, file: Annotated[UploadFile, File()], ) -> DocumentUploadResponse: """Upload a document for analysis using a B2B API key. Reserves one credit from the key owner's account, stores the file, enqueues the extraction job, and returns 202 + document_id/correlation_id for polling. """ result = await upload_and_enqueue(session, storage, publisher, auth.user_id, file) # Record usage for this key. try: key_repo = ApiKeyRepository(session) await key_repo.record_request(auth.api_key_id, uuid.UUID(str(result.document_id))) await key_repo.bump_monthly_used(auth.api_key_id) await session.commit() except Exception as exc: log.error( "api_key_request_log_failed", api_key_id=str(auth.api_key_id), document_id=result.document_id, error=str(exc), ) # Usage logging is best-effort; do not fail the upload. return result @router.get( "/api/v1/b2b/reports/{document_id}", response_model=ReportResponse | ReportInProgressResponse ) async def get_b2b_report( auth: ApiKeyAuthDep, session: AsyncSessionDep, document_id: uuid.UUID, ) -> ReportResponse | ReportInProgressResponse: """Poll for an analysis report scoped to the API key owner.""" row = await fetch_document_status_for_user(session, document_id, auth.user_id) if row is None: raise HTTPException(status_code=404, detail="report not found") doc_status = row["status"] if doc_status != "done": return ReportInProgressResponse( document_id=str(document_id), status=doc_status, stage=row["stage"], ) return ReportResponse( document_id=str(document_id), status=doc_status, filename=row["filename"], markdown=row["markdown"], findings=row["content_json"], model_used=row["model_used"], prompt_tokens=row["prompt_tokens"], eval_tokens=row["eval_tokens"], latency_ms=row["latency_ms"], ) @router.get("/api/v1/b2b/usage", response_model=B2BUsageResponse) async def get_usage( auth: ApiKeyAuthDep, session: AsyncSessionDep, ) -> B2BUsageResponse: """Return current-month usage for the authenticating API key.""" repo = ApiKeyRepository(session) key = await repo.get_by_id(auth.api_key_id) assert key is not None _, monthly_used = await repo.get_monthly_quota_state(auth.api_key_id) return B2BUsageResponse( api_key_id=str(auth.api_key_id), rate_limit_rps=auth.rate_limit_rps, monthly_quota=key.monthly_quota, monthly_used=monthly_used, requests_this_month=await repo.count_requests_this_month(auth.api_key_id), resets_at=key.resets_at.isoformat() if key.resets_at else None, ) # ── Profile (X-API-Key auth; same fields as web, D4) ───────────────────────── @router.get("/api/v1/b2b/profile", response_model=UserProfile) async def get_b2b_profile( auth: ApiKeyAuthDep, session: AsyncSessionDep, ) -> UserProfile: """Return the API key owner's profile (lazy defaults on first read).""" row = await UserProfileRepository(session).get(auth.user_id) await session.commit() return UserProfile( language=row.language, timezone=row.timezone, notif_prefs=dict(row.notif_prefs), # type: ignore[arg-type] dashboard_prefs=dict(row.dashboard_prefs), # type: ignore[arg-type] ) @router.put("/api/v1/b2b/profile", response_model=UserProfile) async def put_b2b_profile( auth: ApiKeyAuthDep, session: AsyncSessionDep, body: UserProfile, ) -> UserProfile: """Full-replace the key owner's profile (idempotent PUT — B2B convention). Intentionally PUT (not PATCH): partners prefer idempotent writes. Billing fields are never exposed to partners (D4). """ repo = UserProfileRepository(session) await repo.get(auth.user_id) # materialize if absent row = await repo.update( auth.user_id, language=body.language, timezone=body.timezone, notif_prefs=body.notif_prefs.model_dump(), dashboard_prefs=body.dashboard_prefs.model_dump(), ) await session.commit() return UserProfile( language=row.language, timezone=row.timezone, notif_prefs=dict(row.notif_prefs), # type: ignore[arg-type] dashboard_prefs=dict(row.dashboard_prefs), # type: ignore[arg-type] ) # ── Key management (service-token auth + telegram_id) ──────────────────────── @router.post( "/api/v1/b2b/keys", response_model=ApiKeyCreatedResponse, status_code=status.HTTP_201_CREATED ) async def create_api_key( session: AsyncSessionDep, user: CurrentUserDep, body: CreateApiKeyRequest, ) -> ApiKeyCreatedResponse: """Create a new B2B API key for the authenticated user. The raw key is returned **only once**; afterwards only its hash is stored. """ rate_limit = body.rate_limit_rps or 3 if rate_limit <= 0: raise HTTPException(status_code=400, detail="rate_limit_rps must be positive") raw_key = generate_api_key() key_hash = hash_api_key(raw_key) try: key = await ApiKeyRepository(session).create( user_id=user.user_id, name=body.name, key_hash=key_hash, rate_limit_rps=rate_limit, monthly_quota=body.monthly_quota, ) await session.commit() except Exception as exc: log.error("api_key_create_failed", user_id=str(user.user_id), error=str(exc)) raise HTTPException(status_code=500, detail="failed to create API key") from exc return ApiKeyCreatedResponse( api_key=raw_key, id=key.id, name=key.name, rate_limit_rps=key.rate_limit_rps, monthly_quota=key.monthly_quota, monthly_used=key.monthly_used, revoked=key.revoked, created_at=key.created_at.isoformat() if key.created_at else None, ) @router.get("/api/v1/b2b/keys", response_model=list[ApiKeyResponse]) async def list_api_keys( session: AsyncSessionDep, user: CurrentUserDep, ) -> list[ApiKeyResponse]: """List B2B API keys for the authenticated user.""" keys = await ApiKeyRepository(session).list_for_user(user.user_id) return [ ApiKeyResponse( id=key.id, name=key.name, rate_limit_rps=key.rate_limit_rps, monthly_quota=key.monthly_quota, monthly_used=key.monthly_used, revoked=key.revoked, created_at=key.created_at.isoformat() if key.created_at else None, ) for key in keys ] @router.post("/api/v1/b2b/keys/{key_id}/revoke", response_model=RevokeApiKeyResponse) async def revoke_api_key( session: AsyncSessionDep, user: CurrentUserDep, key_id: uuid.UUID, ) -> RevokeApiKeyResponse: """Revoke a B2B API key. Only keys owned by the user may be revoked.""" key = await ApiKeyRepository(session).get_by_id(key_id) if key is None or key.user_id != user.user_id or key.revoked: raise HTTPException(status_code=404, detail="key not found or already revoked") await ApiKeyRepository(session).revoke(key_id) await session.commit() return RevokeApiKeyResponse(id=str(key_id), revoked=True) @router.get("/api/v1/b2b/keys/{key_id}/usage", response_model=ApiKeyUsageDetailResponse) async def get_key_usage( session: AsyncSessionDep, user: CurrentUserDep, key_id: uuid.UUID, ) -> ApiKeyUsageDetailResponse: """Return per-month usage for a specific API key owned by the user.""" repo = ApiKeyRepository(session) key = await repo.get_by_id(key_id) if key is None or key.user_id != user.user_id: raise HTTPException(status_code=404, detail="key not found") monthly_result = await repo.get_usage_by_key(key_id) return ApiKeyUsageDetailResponse( key_id=str(key_id), name=key.name, rate_limit_rps=key.rate_limit_rps, monthly_quota=key.monthly_quota, monthly_used=key.monthly_used, resets_at=key.resets_at.isoformat() if key.resets_at else None, monthly_requests=[ {"month": req.created_at.isoformat() if req.created_at else None, "requests": 1} for req in monthly_result ], )