""" Yield Domain API endpoints. Manages domain activation for yield/intent routing and revenue tracking. """ import json from datetime import datetime, timedelta from decimal import Decimal from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy import func, and_, or_, Integer, case, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_db, get_current_user from app.models.user import User from app.models.yield_domain import YieldDomain, YieldTransaction, YieldPayout, AffiliatePartner from app.config import get_settings settings = get_settings() from app.schemas.yield_domain import ( YieldDomainCreate, YieldDomainUpdate, YieldDomainResponse, YieldDomainListResponse, YieldTransactionResponse, YieldTransactionListResponse, YieldPayoutResponse, YieldPayoutListResponse, YieldDashboardStats, YieldDashboardResponse, DomainYieldAnalysis, IntentAnalysis, YieldValueEstimate, AffiliatePartnerResponse, DNSVerificationResult, DNSSetupInstructions, ActivateYieldRequest, ActivateYieldResponse, ) from app.services.intent_detector import ( detect_domain_intent, estimate_domain_yield, get_intent_detector, ) router = APIRouter(prefix="/yield", tags=["yield"]) # DNS Configuration (would be in config in production) YIELD_NAMESERVERS = ["ns1.pounce.io", "ns2.pounce.io"] YIELD_CNAME_TARGET = "yield.pounce.io" # ============================================================================ # Intent Analysis (Public) # ============================================================================ @router.post("/analyze", response_model=DomainYieldAnalysis) async def analyze_domain_intent( domain: str = Query(..., min_length=3, description="Domain to analyze"), ): """ Analyze a domain's intent and estimate yield potential. This endpoint is public - no authentication required. """ analysis = estimate_domain_yield(domain) intent_result = detect_domain_intent(domain) return DomainYieldAnalysis( domain=domain, intent=IntentAnalysis( category=intent_result.category, subcategory=intent_result.subcategory, confidence=intent_result.confidence, keywords_matched=intent_result.keywords_matched, suggested_partners=intent_result.suggested_partners, monetization_potential=intent_result.monetization_potential, ), value=YieldValueEstimate( estimated_monthly_min=analysis["value"]["estimated_monthly_min"], estimated_monthly_max=analysis["value"]["estimated_monthly_max"], currency=analysis["value"]["currency"], potential=analysis["value"]["potential"], confidence=analysis["value"]["confidence"], geo=analysis["value"]["geo"], ), partners=analysis["partners"], monetization_potential=analysis["monetization_potential"], ) # ============================================================================ # Dashboard # ============================================================================ @router.get("/dashboard", response_model=YieldDashboardResponse) async def get_yield_dashboard( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Get yield dashboard with stats, domains, and recent transactions. """ # Get user's yield domains result = await db.execute( select(YieldDomain) .where(YieldDomain.user_id == current_user.id) .order_by(YieldDomain.total_revenue.desc()) ) domains = list(result.scalars().all()) # Calculate stats now = datetime.utcnow() month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) # Monthly stats from transactions (simplified for async) monthly_revenue = Decimal("0") monthly_clicks = 0 monthly_conversions = 0 if domains: domain_ids = [d.id for d in domains] monthly_result = await db.execute( select( func.count(YieldTransaction.id).label("count"), func.coalesce(func.sum(YieldTransaction.net_amount), 0).label("revenue"), func.sum(case((YieldTransaction.event_type == "click", 1), else_=0)).label("clicks"), func.sum(case((YieldTransaction.event_type.in_(["lead", "sale"]), 1), else_=0)).label("conversions"), ).where( YieldTransaction.yield_domain_id.in_(domain_ids), YieldTransaction.created_at >= month_start, ) ) monthly_stats = monthly_result.first() if monthly_stats: monthly_revenue = monthly_stats.revenue or Decimal("0") monthly_clicks = monthly_stats.clicks or 0 monthly_conversions = monthly_stats.conversions or 0 # Aggregate domain stats total_active = sum(1 for d in domains if d.status == "active") total_pending = sum(1 for d in domains if d.status in ["pending", "verifying"]) lifetime_revenue = sum(d.total_revenue for d in domains) lifetime_clicks = sum(d.total_clicks for d in domains) lifetime_conversions = sum(d.total_conversions for d in domains) # Pending payout pending_payout = Decimal("0") if domains: domain_ids = [d.id for d in domains] pending_result = await db.execute( select(func.coalesce(func.sum(YieldTransaction.net_amount), 0)).where( YieldTransaction.yield_domain_id.in_(domain_ids), YieldTransaction.status == "confirmed", YieldTransaction.paid_at.is_(None), ) ) pending_payout = pending_result.scalar() or Decimal("0") # Get recent transactions recent_txs = [] if domains: domain_ids = [d.id for d in domains] recent_result = await db.execute( select(YieldTransaction) .where(YieldTransaction.yield_domain_id.in_(domain_ids)) .order_by(YieldTransaction.created_at.desc()) .limit(10) ) recent_txs = list(recent_result.scalars().all()) # Top performing domains top_domains = sorted(domains, key=lambda d: d.total_revenue, reverse=True)[:5] stats = YieldDashboardStats( total_domains=len(domains), active_domains=total_active, pending_domains=total_pending, monthly_revenue=monthly_revenue, monthly_clicks=monthly_clicks, monthly_conversions=monthly_conversions, lifetime_revenue=lifetime_revenue, lifetime_clicks=lifetime_clicks, lifetime_conversions=lifetime_conversions, pending_payout=pending_payout, next_payout_date=month_start + timedelta(days=32), # Approx next month currency="CHF", ) return YieldDashboardResponse( stats=stats, domains=[_domain_to_response(d) for d in domains], recent_transactions=[_tx_to_response(tx) for tx in recent_txs], top_domains=[_domain_to_response(d) for d in top_domains], ) # ============================================================================ # Domain Management # ============================================================================ @router.get("/domains", response_model=YieldDomainListResponse) async def list_yield_domains( status: Optional[str] = Query(None, description="Filter by status"), limit: int = Query(50, le=100), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ List user's yield domains. """ query = select(YieldDomain).where(YieldDomain.user_id == current_user.id) if status: query = query.where(YieldDomain.status == status) # Get total count count_result = await db.execute( select(func.count(YieldDomain.id)).where(YieldDomain.user_id == current_user.id) ) total = count_result.scalar() or 0 # Get domains result = await db.execute( query.order_by(YieldDomain.created_at.desc()).offset(offset).limit(limit) ) domains = list(result.scalars().all()) # Aggregates from all domains all_result = await db.execute( select(YieldDomain).where(YieldDomain.user_id == current_user.id) ) all_domains = list(all_result.scalars().all()) total_active = sum(1 for d in all_domains if d.status == "active") total_revenue = sum(d.total_revenue for d in all_domains) total_clicks = sum(d.total_clicks for d in all_domains) return YieldDomainListResponse( domains=[_domain_to_response(d) for d in domains], total=total, total_active=total_active, total_revenue=total_revenue, total_clicks=total_clicks, ) @router.get("/domains/{domain_id}", response_model=YieldDomainResponse) async def get_yield_domain( domain_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Get details of a specific yield domain. """ result = await db.execute( select(YieldDomain).where( YieldDomain.id == domain_id, YieldDomain.user_id == current_user.id, ) ) domain = result.scalar_one_or_none() if not domain: raise HTTPException(status_code=404, detail="Yield domain not found") return _domain_to_response(domain) @router.post("/activate", response_model=ActivateYieldResponse) async def activate_domain_for_yield( request: ActivateYieldRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Activate a domain for yield/intent routing. This creates the yield domain record and returns DNS setup instructions. """ domain = request.domain.lower().strip() # Check if domain already exists existing_result = await db.execute( select(YieldDomain).where(YieldDomain.domain == domain) ) existing = existing_result.scalar_one_or_none() if existing: if existing.user_id == current_user.id: raise HTTPException( status_code=400, detail="Domain already activated for yield" ) else: raise HTTPException( status_code=400, detail="Domain is already registered by another user" ) # Analyze domain intent intent_result = detect_domain_intent(domain) value_estimate = get_intent_detector().estimate_value(domain) # Create yield domain record yield_domain = YieldDomain( user_id=current_user.id, domain=domain, detected_intent=f"{intent_result.category}_{intent_result.subcategory}" if intent_result.subcategory else intent_result.category, intent_confidence=intent_result.confidence, intent_keywords=json.dumps(intent_result.keywords_matched), status="pending", ) # Find best matching partner if intent_result.suggested_partners: partner_result = await db.execute( select(AffiliatePartner).where( AffiliatePartner.slug == intent_result.suggested_partners[0], AffiliatePartner.is_active == True, ) ) partner = partner_result.scalar_one_or_none() if partner: yield_domain.partner_id = partner.id yield_domain.active_route = partner.slug db.add(yield_domain) await db.commit() await db.refresh(yield_domain) # Create DNS instructions dns_instructions = DNSSetupInstructions( domain=domain, nameservers=YIELD_NAMESERVERS, cname_host="@", cname_target=YIELD_CNAME_TARGET, verification_url=f"{settings.site_url}/api/v1/yield/verify/{yield_domain.id}", ) return ActivateYieldResponse( domain_id=yield_domain.id, domain=domain, status=yield_domain.status, intent=IntentAnalysis( category=intent_result.category, subcategory=intent_result.subcategory, confidence=intent_result.confidence, keywords_matched=intent_result.keywords_matched, suggested_partners=intent_result.suggested_partners, monetization_potential=intent_result.monetization_potential, ), value_estimate=YieldValueEstimate( estimated_monthly_min=value_estimate["estimated_monthly_min"], estimated_monthly_max=value_estimate["estimated_monthly_max"], currency=value_estimate["currency"], potential=value_estimate["potential"], confidence=value_estimate["confidence"], geo=value_estimate["geo"], ), dns_instructions=dns_instructions, message="Domain registered! Point your DNS to our nameservers to complete activation.", ) @router.post("/domains/{domain_id}/verify", response_model=DNSVerificationResult) async def verify_domain_dns( domain_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Verify DNS configuration for a yield domain. """ result = await db.execute( select(YieldDomain).where( YieldDomain.id == domain_id, YieldDomain.user_id == current_user.id, ) ) domain = result.scalar_one_or_none() if not domain: raise HTTPException(status_code=404, detail="Yield domain not found") # Perform DNS check (simplified - in production use dnspython) verified = False actual_ns = [] error = None try: import dns.resolver # Check nameservers try: answers = dns.resolver.resolve(domain.domain, 'NS') actual_ns = [str(rr.target).rstrip('.') for rr in answers] # Check if our nameservers are set our_ns_set = set(ns.lower() for ns in YIELD_NAMESERVERS) actual_ns_set = set(ns.lower() for ns in actual_ns) if our_ns_set.issubset(actual_ns_set): verified = True except dns.resolver.NXDOMAIN: error = "Domain does not exist" except dns.resolver.NoAnswer: # Try CNAME instead try: cname_answers = dns.resolver.resolve(domain.domain, 'CNAME') for rr in cname_answers: if str(rr.target).rstrip('.').lower() == YIELD_CNAME_TARGET.lower(): verified = True break except Exception: error = "No NS or CNAME records found" except Exception as e: error = str(e) except ImportError: # dnspython not installed - simulate for development verified = True # Auto-verify in dev actual_ns = YIELD_NAMESERVERS # Update domain status if verified and not domain.dns_verified: domain.dns_verified = True domain.dns_verified_at = datetime.utcnow() domain.status = "active" domain.activated_at = datetime.utcnow() await db.commit() return DNSVerificationResult( domain=domain.domain, verified=verified, expected_ns=YIELD_NAMESERVERS, actual_ns=actual_ns, cname_ok=verified and not actual_ns, error=error, checked_at=datetime.utcnow(), ) @router.patch("/domains/{domain_id}", response_model=YieldDomainResponse) async def update_yield_domain( domain_id: int, update: YieldDomainUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Update yield domain settings. """ result = await db.execute( select(YieldDomain).where( YieldDomain.id == domain_id, YieldDomain.user_id == current_user.id, ) ) domain = result.scalar_one_or_none() if not domain: raise HTTPException(status_code=404, detail="Yield domain not found") # Apply updates if update.active_route is not None: # Validate partner exists partner_result = await db.execute( select(AffiliatePartner).where( AffiliatePartner.slug == update.active_route, AffiliatePartner.is_active == True, ) ) partner = partner_result.scalar_one_or_none() if not partner: raise HTTPException(status_code=400, detail="Invalid partner route") domain.active_route = update.active_route domain.partner_id = partner.id if update.landing_page_url is not None: domain.landing_page_url = update.landing_page_url if update.status is not None: if update.status == "paused": domain.status = "paused" domain.paused_at = datetime.utcnow() elif update.status == "active" and domain.dns_verified: domain.status = "active" domain.paused_at = None await db.commit() await db.refresh(domain) return _domain_to_response(domain) @router.delete("/domains/{domain_id}") async def delete_yield_domain( domain_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Remove a domain from yield program. """ result = await db.execute( select(YieldDomain).where( YieldDomain.id == domain_id, YieldDomain.user_id == current_user.id, ) ) domain = result.scalar_one_or_none() if not domain: raise HTTPException(status_code=404, detail="Yield domain not found") await db.delete(domain) await db.commit() return {"message": "Yield domain removed"} # ============================================================================ # Transactions # ============================================================================ @router.get("/transactions", response_model=YieldTransactionListResponse) async def list_transactions( domain_id: Optional[int] = Query(None), status: Optional[str] = Query(None), limit: int = Query(50, le=100), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ List yield transactions for user's domains. """ # Get user's domain IDs domain_ids_result = await db.execute( select(YieldDomain.id).where(YieldDomain.user_id == current_user.id) ) domain_ids = [row[0] for row in domain_ids_result.all()] if not domain_ids: return YieldTransactionListResponse( transactions=[], total=0, total_gross=Decimal("0"), total_net=Decimal("0"), ) query = select(YieldTransaction).where( YieldTransaction.yield_domain_id.in_(domain_ids) ) if domain_id: query = query.where(YieldTransaction.yield_domain_id == domain_id) if status: query = query.where(YieldTransaction.status == status) # Get count count_query = select(func.count(YieldTransaction.id)).where( YieldTransaction.yield_domain_id.in_(domain_ids) ) if domain_id: count_query = count_query.where(YieldTransaction.yield_domain_id == domain_id) if status: count_query = count_query.where(YieldTransaction.status == status) count_result = await db.execute(count_query) total = count_result.scalar() or 0 # Get transactions result = await db.execute( query.order_by(YieldTransaction.created_at.desc()).offset(offset).limit(limit) ) transactions = list(result.scalars().all()) # Aggregates total_gross = sum(tx.gross_amount for tx in transactions) total_net = sum(tx.net_amount for tx in transactions) return YieldTransactionListResponse( transactions=[_tx_to_response(tx) for tx in transactions], total=total, total_gross=total_gross, total_net=total_net, ) # ============================================================================ # Payouts # ============================================================================ @router.get("/payouts", response_model=YieldPayoutListResponse) async def list_payouts( status: Optional[str] = Query(None), limit: int = Query(20, le=50), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ List user's yield payouts. """ query = select(YieldPayout).where(YieldPayout.user_id == current_user.id) if status: query = query.where(YieldPayout.status == status) # Get count count_result = await db.execute( select(func.count(YieldPayout.id)).where(YieldPayout.user_id == current_user.id) ) total = count_result.scalar() or 0 # Get payouts result = await db.execute( query.order_by(YieldPayout.created_at.desc()).offset(offset).limit(limit) ) payouts = list(result.scalars().all()) # Aggregates total_paid = sum(p.amount for p in payouts if p.status == "completed") total_pending = sum(p.amount for p in payouts if p.status in ["pending", "processing"]) return YieldPayoutListResponse( payouts=[_payout_to_response(p) for p in payouts], total=total, total_paid=total_paid, total_pending=total_pending, ) # ============================================================================ # Partners (Public info) # ============================================================================ @router.get("/partners", response_model=list[AffiliatePartnerResponse]) async def list_partners( category: Optional[str] = Query(None, description="Filter by intent category"), db: AsyncSession = Depends(get_db), ): """ List available affiliate partners. """ result = await db.execute( select(AffiliatePartner) .where(AffiliatePartner.is_active == True) .order_by(AffiliatePartner.priority.desc()) ) partners = list(result.scalars().all()) # Filter by category if specified if category: partners = [p for p in partners if category in p.intent_list] return [ AffiliatePartnerResponse( slug=p.slug, name=p.name, network=p.network, intent_categories=p.intent_list, geo_countries=p.country_list, payout_type=p.payout_type, description=p.description, logo_url=p.logo_url, ) for p in partners ] # ============================================================================ # Helpers # ============================================================================ def _domain_to_response(domain: YieldDomain) -> YieldDomainResponse: """Convert YieldDomain model to response schema.""" return YieldDomainResponse( id=domain.id, domain=domain.domain, status=domain.status, detected_intent=domain.detected_intent, intent_confidence=domain.intent_confidence, active_route=domain.active_route, partner_name=domain.partner.name if domain.partner else None, dns_verified=domain.dns_verified, dns_verified_at=domain.dns_verified_at, total_clicks=domain.total_clicks, total_conversions=domain.total_conversions, total_revenue=domain.total_revenue, currency=domain.currency, activated_at=domain.activated_at, created_at=domain.created_at, ) def _tx_to_response(tx: YieldTransaction) -> YieldTransactionResponse: """Convert YieldTransaction model to response schema.""" return YieldTransactionResponse( id=tx.id, event_type=tx.event_type, partner_slug=tx.partner_slug, gross_amount=tx.gross_amount, net_amount=tx.net_amount, currency=tx.currency, status=tx.status, geo_country=tx.geo_country, created_at=tx.created_at, confirmed_at=tx.confirmed_at, ) def _payout_to_response(payout: YieldPayout) -> YieldPayoutResponse: """Convert YieldPayout model to response schema.""" return YieldPayoutResponse( id=payout.id, amount=payout.amount, currency=payout.currency, period_start=payout.period_start, period_end=payout.period_end, transaction_count=payout.transaction_count, status=payout.status, payment_method=payout.payment_method, payment_reference=payout.payment_reference, created_at=payout.created_at, completed_at=payout.completed_at, )