pounce/backend/app/api/yield_domains.py
Yves Gugger 4a1ebf0024
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
feat: Mobile-optimized landing page + Header drawer + Footer improvements
2025-12-13 17:33:08 +01:00

736 lines
24 KiB
Python

"""
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,
)