""" FastAPI routes for Compliance Scope persistence. Stores the tenant's scope decision (frameworks, regulations, industry context) in sdk_states.state->compliance_scope as JSONB. Endpoints: - GET /v1/compliance-scope?tenant_id=... → returns scope or 404 - POST /v1/compliance-scope → UPSERT scope (idempotent) """ import json import logging from typing import Any, Optional from fastapi import APIRouter, HTTPException, Header from pydantic import BaseModel from sqlalchemy import text from database import SessionLocal logger = logging.getLogger(__name__) router = APIRouter(prefix="/v1/compliance-scope", tags=["compliance-scope"]) # ============================================================================= # REQUEST / RESPONSE MODELS # ============================================================================= class ComplianceScopeRequest(BaseModel): """Scope selection submitted by the frontend wizard.""" scope: dict[str, Any] tenant_id: Optional[str] = None class ComplianceScopeResponse(BaseModel): """Persisted scope object returned to the frontend.""" tenant_id: str scope: dict[str, Any] updated_at: str created_at: str # ============================================================================= # HELPERS # ============================================================================= def _get_tid( x_tenant_id: Optional[str], query_tenant_id: str, ) -> str: return x_tenant_id or query_tenant_id or "default" def _row_to_response(row) -> ComplianceScopeResponse: """Convert a DB row (tenant_id, scope, created_at, updated_at) to response.""" return ComplianceScopeResponse( tenant_id=row[0], scope=row[1] if isinstance(row[1], dict) else {}, created_at=str(row[2]), updated_at=str(row[3]), ) # ============================================================================= # ROUTES # ============================================================================= @router.get("", response_model=ComplianceScopeResponse) async def get_compliance_scope( tenant_id: str = "default", x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), ): """Return the persisted compliance scope for a tenant, or 404 if not set.""" tid = _get_tid(x_tenant_id, tenant_id) db = SessionLocal() try: row = db.execute( text("""SELECT tenant_id, state->'compliance_scope' AS scope, created_at, updated_at FROM sdk_states WHERE tenant_id = :tid AND state ? 'compliance_scope'"""), {"tid": tid}, ).fetchone() if not row or row[1] is None: raise HTTPException(status_code=404, detail="Compliance scope not found") return _row_to_response(row) finally: db.close() @router.post("", response_model=ComplianceScopeResponse) async def upsert_compliance_scope( body: ComplianceScopeRequest, tenant_id: str = "default", x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), ): """Create or update the compliance scope for a tenant (UPSERT).""" tid = _get_tid(x_tenant_id, body.tenant_id or tenant_id) scope_json = json.dumps(body.scope) db = SessionLocal() try: db.execute( text("""INSERT INTO sdk_states (tenant_id, state) VALUES (:tid, jsonb_build_object('compliance_scope', :scope::jsonb)) ON CONFLICT (tenant_id) DO UPDATE SET state = sdk_states.state || jsonb_build_object('compliance_scope', :scope::jsonb), updated_at = NOW()"""), {"tid": tid, "scope": scope_json}, ) db.commit() row = db.execute( text("""SELECT tenant_id, state->'compliance_scope' AS scope, created_at, updated_at FROM sdk_states WHERE tenant_id = :tid"""), {"tid": tid}, ).fetchone() return _row_to_response(row) except Exception as e: db.rollback() logger.error(f"Failed to upsert compliance scope: {e}") raise HTTPException(status_code=500, detail="Failed to save compliance scope") finally: db.close()