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
887 lines
29 KiB
Python
887 lines
29 KiB
Python
"""Portfolio API routes."""
|
|
import secrets
|
|
from datetime import datetime
|
|
from typing import Optional, List
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select, func, and_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
import dns.resolver
|
|
|
|
from app.database import get_db
|
|
from app.api.deps import get_current_user
|
|
from app.models.user import User
|
|
from app.models.portfolio import PortfolioDomain, DomainValuation
|
|
from app.services.valuation import valuation_service
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ============== Schemas ==============
|
|
|
|
class PortfolioDomainCreate(BaseModel):
|
|
"""Schema for creating a portfolio domain."""
|
|
domain: str = Field(..., min_length=3, max_length=255)
|
|
purchase_date: Optional[datetime] = None
|
|
purchase_price: Optional[float] = Field(None, ge=0)
|
|
purchase_registrar: Optional[str] = None
|
|
registrar: Optional[str] = None
|
|
renewal_date: Optional[datetime] = None
|
|
renewal_cost: Optional[float] = Field(None, ge=0)
|
|
auto_renew: bool = True
|
|
notes: Optional[str] = None
|
|
tags: Optional[str] = None
|
|
|
|
|
|
class PortfolioDomainUpdate(BaseModel):
|
|
"""Schema for updating a portfolio domain."""
|
|
purchase_date: Optional[datetime] = None
|
|
purchase_price: Optional[float] = Field(None, ge=0)
|
|
purchase_registrar: Optional[str] = None
|
|
registrar: Optional[str] = None
|
|
renewal_date: Optional[datetime] = None
|
|
renewal_cost: Optional[float] = Field(None, ge=0)
|
|
auto_renew: Optional[bool] = None
|
|
status: Optional[str] = None
|
|
notes: Optional[str] = None
|
|
tags: Optional[str] = None
|
|
|
|
|
|
class PortfolioDomainSell(BaseModel):
|
|
"""Schema for marking a domain as sold."""
|
|
sale_date: datetime
|
|
sale_price: float = Field(..., ge=0)
|
|
|
|
|
|
class PortfolioDomainResponse(BaseModel):
|
|
"""Response schema for portfolio domain."""
|
|
id: int
|
|
domain: str
|
|
purchase_date: Optional[datetime]
|
|
purchase_price: Optional[float]
|
|
purchase_registrar: Optional[str]
|
|
registrar: Optional[str]
|
|
renewal_date: Optional[datetime]
|
|
renewal_cost: Optional[float]
|
|
auto_renew: bool
|
|
estimated_value: Optional[float]
|
|
value_updated_at: Optional[datetime]
|
|
is_sold: bool
|
|
sale_date: Optional[datetime]
|
|
sale_price: Optional[float]
|
|
status: str
|
|
notes: Optional[str]
|
|
tags: Optional[str]
|
|
roi: Optional[float]
|
|
# DNS Verification fields
|
|
is_dns_verified: bool = False
|
|
verification_status: str = "unverified"
|
|
verification_code: Optional[str] = None
|
|
verified_at: Optional[datetime] = None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class DNSVerificationStartResponse(BaseModel):
|
|
"""Response when starting DNS verification."""
|
|
domain_id: int
|
|
domain: str
|
|
verification_code: str
|
|
dns_record_type: str
|
|
dns_record_name: str
|
|
dns_record_value: str
|
|
instructions: str
|
|
status: str
|
|
|
|
|
|
class DNSVerificationCheckResponse(BaseModel):
|
|
"""Response when checking DNS verification."""
|
|
verified: bool
|
|
status: str
|
|
message: str
|
|
|
|
|
|
class PortfolioSummary(BaseModel):
|
|
"""Summary of user's portfolio."""
|
|
total_domains: int
|
|
active_domains: int
|
|
sold_domains: int
|
|
total_invested: float
|
|
total_value: float
|
|
total_sold_value: float
|
|
unrealized_profit: float
|
|
realized_profit: float
|
|
overall_roi: float
|
|
|
|
|
|
class ValuationScores(BaseModel):
|
|
"""Domain valuation scores breakdown."""
|
|
length: int
|
|
tld: int
|
|
keyword: int
|
|
brandability: int
|
|
overall: int
|
|
|
|
|
|
class ValuationFactors(BaseModel):
|
|
"""Domain valuation factors."""
|
|
length: int
|
|
tld: str
|
|
has_numbers: bool
|
|
has_hyphens: bool
|
|
is_dictionary_word: bool
|
|
detected_keywords: List[str] = []
|
|
|
|
|
|
class ValuationCalculation(BaseModel):
|
|
"""Transparent calculation breakdown."""
|
|
base_value: float
|
|
length_factor: float
|
|
length_reason: str
|
|
tld_factor: float
|
|
tld_reason: str
|
|
keyword_factor: float
|
|
keyword_reason: str
|
|
brand_factor: float
|
|
brand_reason: str
|
|
formula: str
|
|
raw_result: float
|
|
|
|
|
|
class RegistrationContext(BaseModel):
|
|
"""TLD registration cost context."""
|
|
tld_cost: Optional[float] = None
|
|
value_to_cost_ratio: Optional[float] = None
|
|
|
|
|
|
class ValuationResponse(BaseModel):
|
|
"""Response schema for domain valuation - fully transparent."""
|
|
domain: str
|
|
estimated_value: float
|
|
currency: str
|
|
confidence: str
|
|
|
|
# Detailed breakdowns
|
|
scores: ValuationScores
|
|
factors: ValuationFactors
|
|
calculation: ValuationCalculation
|
|
registration_context: RegistrationContext
|
|
|
|
# Metadata
|
|
source: str
|
|
calculated_at: str
|
|
disclaimer: str
|
|
|
|
|
|
# ============== Portfolio Endpoints ==============
|
|
|
|
@router.get("", response_model=List[PortfolioDomainResponse])
|
|
async def get_portfolio(
|
|
status: Optional[str] = Query(None, description="Filter by status"),
|
|
sort_by: str = Query("created_at", description="Sort field"),
|
|
sort_order: str = Query("desc", description="Sort order (asc/desc)"),
|
|
limit: int = Query(100, le=500),
|
|
offset: int = Query(0, ge=0),
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get user's portfolio domains."""
|
|
query = select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id)
|
|
|
|
# Filter by status
|
|
if status:
|
|
query = query.where(PortfolioDomain.status == status)
|
|
|
|
# Sorting
|
|
sort_column = getattr(PortfolioDomain, sort_by, PortfolioDomain.created_at)
|
|
if sort_order == "asc":
|
|
query = query.order_by(sort_column.asc())
|
|
else:
|
|
query = query.order_by(sort_column.desc())
|
|
|
|
# Pagination
|
|
query = query.offset(offset).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
domains = result.scalars().all()
|
|
|
|
# Calculate ROI for each domain
|
|
responses = []
|
|
for d in domains:
|
|
response = PortfolioDomainResponse(
|
|
id=d.id,
|
|
domain=d.domain,
|
|
purchase_date=d.purchase_date,
|
|
purchase_price=d.purchase_price,
|
|
purchase_registrar=d.purchase_registrar,
|
|
registrar=d.registrar,
|
|
renewal_date=d.renewal_date,
|
|
renewal_cost=d.renewal_cost,
|
|
auto_renew=d.auto_renew,
|
|
estimated_value=d.estimated_value,
|
|
value_updated_at=d.value_updated_at,
|
|
is_sold=d.is_sold,
|
|
sale_date=d.sale_date,
|
|
sale_price=d.sale_price,
|
|
status=d.status,
|
|
notes=d.notes,
|
|
tags=d.tags,
|
|
roi=d.roi,
|
|
is_dns_verified=getattr(d, 'is_dns_verified', False) or False,
|
|
verification_status=getattr(d, 'verification_status', 'unverified') or 'unverified',
|
|
verification_code=getattr(d, 'verification_code', None),
|
|
verified_at=getattr(d, 'verified_at', None),
|
|
created_at=d.created_at,
|
|
updated_at=d.updated_at,
|
|
)
|
|
responses.append(response)
|
|
|
|
return responses
|
|
|
|
|
|
@router.get("/summary", response_model=PortfolioSummary)
|
|
async def get_portfolio_summary(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get portfolio summary statistics."""
|
|
result = await db.execute(
|
|
select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id)
|
|
)
|
|
domains = result.scalars().all()
|
|
|
|
total_domains = len(domains)
|
|
active_domains = sum(1 for d in domains if d.status == "active" and not d.is_sold)
|
|
sold_domains = sum(1 for d in domains if d.is_sold)
|
|
|
|
total_invested = sum(d.purchase_price or 0 for d in domains)
|
|
total_value = sum(d.estimated_value or 0 for d in domains if not d.is_sold)
|
|
total_sold_value = sum(d.sale_price or 0 for d in domains if d.is_sold)
|
|
|
|
# Calculate active investment for ROI
|
|
active_investment = sum(d.purchase_price or 0 for d in domains if not d.is_sold)
|
|
sold_investment = sum(d.purchase_price or 0 for d in domains if d.is_sold)
|
|
|
|
unrealized_profit = total_value - active_investment
|
|
realized_profit = total_sold_value - sold_investment
|
|
|
|
overall_roi = 0.0
|
|
if total_invested > 0:
|
|
overall_roi = ((total_value + total_sold_value - total_invested) / total_invested) * 100
|
|
|
|
return PortfolioSummary(
|
|
total_domains=total_domains,
|
|
active_domains=active_domains,
|
|
sold_domains=sold_domains,
|
|
total_invested=round(total_invested, 2),
|
|
total_value=round(total_value, 2),
|
|
total_sold_value=round(total_sold_value, 2),
|
|
unrealized_profit=round(unrealized_profit, 2),
|
|
realized_profit=round(realized_profit, 2),
|
|
overall_roi=round(overall_roi, 2),
|
|
)
|
|
|
|
|
|
@router.post("", response_model=PortfolioDomainResponse, status_code=status.HTTP_201_CREATED)
|
|
async def add_portfolio_domain(
|
|
data: PortfolioDomainCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Add a domain to portfolio."""
|
|
from app.models.subscription import Subscription, SubscriptionTier, TIER_CONFIG
|
|
|
|
# Check subscription portfolio limit
|
|
await db.refresh(current_user, ["subscription"])
|
|
|
|
if current_user.subscription:
|
|
portfolio_limit = current_user.subscription.portfolio_limit
|
|
else:
|
|
portfolio_limit = TIER_CONFIG[SubscriptionTier.SCOUT].get("portfolio_limit", 0)
|
|
|
|
# Count current portfolio domains
|
|
count_result = await db.execute(
|
|
select(func.count(PortfolioDomain.id)).where(
|
|
PortfolioDomain.user_id == current_user.id
|
|
)
|
|
)
|
|
current_count = count_result.scalar() or 0
|
|
|
|
# Check limit (-1 means unlimited)
|
|
if portfolio_limit != -1 and current_count >= portfolio_limit:
|
|
if portfolio_limit == 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Portfolio feature not available on Scout plan. Upgrade to Trader or Tycoon.",
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=f"Portfolio limit reached ({portfolio_limit} domains). Upgrade to add more.",
|
|
)
|
|
|
|
# Check if domain already exists in user's portfolio
|
|
existing = await db.execute(
|
|
select(PortfolioDomain).where(
|
|
and_(
|
|
PortfolioDomain.user_id == current_user.id,
|
|
PortfolioDomain.domain == data.domain.lower(),
|
|
)
|
|
)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Domain already in portfolio",
|
|
)
|
|
|
|
# Get initial valuation
|
|
valuation = await valuation_service.estimate_value(data.domain, db, save_result=True)
|
|
estimated_value = valuation.get("estimated_value") if "error" not in valuation else None
|
|
|
|
# Create portfolio entry
|
|
domain = PortfolioDomain(
|
|
user_id=current_user.id,
|
|
domain=data.domain.lower(),
|
|
purchase_date=data.purchase_date,
|
|
purchase_price=data.purchase_price,
|
|
purchase_registrar=data.purchase_registrar,
|
|
registrar=data.registrar or data.purchase_registrar,
|
|
renewal_date=data.renewal_date,
|
|
renewal_cost=data.renewal_cost,
|
|
auto_renew=data.auto_renew,
|
|
estimated_value=estimated_value,
|
|
value_updated_at=datetime.utcnow() if estimated_value else None,
|
|
notes=data.notes,
|
|
tags=data.tags,
|
|
)
|
|
|
|
db.add(domain)
|
|
await db.commit()
|
|
await db.refresh(domain)
|
|
|
|
return PortfolioDomainResponse(
|
|
id=domain.id,
|
|
domain=domain.domain,
|
|
purchase_date=domain.purchase_date,
|
|
purchase_price=domain.purchase_price,
|
|
purchase_registrar=domain.purchase_registrar,
|
|
registrar=domain.registrar,
|
|
renewal_date=domain.renewal_date,
|
|
renewal_cost=domain.renewal_cost,
|
|
auto_renew=domain.auto_renew,
|
|
estimated_value=domain.estimated_value,
|
|
value_updated_at=domain.value_updated_at,
|
|
is_sold=domain.is_sold,
|
|
sale_date=domain.sale_date,
|
|
sale_price=domain.sale_price,
|
|
status=domain.status,
|
|
notes=domain.notes,
|
|
tags=domain.tags,
|
|
roi=domain.roi,
|
|
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
|
|
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
|
|
verification_code=getattr(domain, 'verification_code', None),
|
|
verified_at=getattr(domain, 'verified_at', None),
|
|
created_at=domain.created_at,
|
|
updated_at=domain.updated_at,
|
|
)
|
|
|
|
|
|
@router.get("/{domain_id}", response_model=PortfolioDomainResponse)
|
|
async def get_portfolio_domain(
|
|
domain_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get a specific portfolio domain."""
|
|
result = await db.execute(
|
|
select(PortfolioDomain).where(
|
|
and_(
|
|
PortfolioDomain.id == domain_id,
|
|
PortfolioDomain.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
domain = result.scalar_one_or_none()
|
|
|
|
if not domain:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Domain not found in portfolio",
|
|
)
|
|
|
|
return PortfolioDomainResponse(
|
|
id=domain.id,
|
|
domain=domain.domain,
|
|
purchase_date=domain.purchase_date,
|
|
purchase_price=domain.purchase_price,
|
|
purchase_registrar=domain.purchase_registrar,
|
|
registrar=domain.registrar,
|
|
renewal_date=domain.renewal_date,
|
|
renewal_cost=domain.renewal_cost,
|
|
auto_renew=domain.auto_renew,
|
|
estimated_value=domain.estimated_value,
|
|
value_updated_at=domain.value_updated_at,
|
|
is_sold=domain.is_sold,
|
|
sale_date=domain.sale_date,
|
|
sale_price=domain.sale_price,
|
|
status=domain.status,
|
|
notes=domain.notes,
|
|
tags=domain.tags,
|
|
roi=domain.roi,
|
|
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
|
|
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
|
|
verification_code=getattr(domain, 'verification_code', None),
|
|
verified_at=getattr(domain, 'verified_at', None),
|
|
created_at=domain.created_at,
|
|
updated_at=domain.updated_at,
|
|
)
|
|
|
|
|
|
@router.put("/{domain_id}", response_model=PortfolioDomainResponse)
|
|
async def update_portfolio_domain(
|
|
domain_id: int,
|
|
data: PortfolioDomainUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update a portfolio domain."""
|
|
result = await db.execute(
|
|
select(PortfolioDomain).where(
|
|
and_(
|
|
PortfolioDomain.id == domain_id,
|
|
PortfolioDomain.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
domain = result.scalar_one_or_none()
|
|
|
|
if not domain:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Domain not found in portfolio",
|
|
)
|
|
|
|
# Update fields
|
|
update_data = data.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(domain, field, value)
|
|
|
|
await db.commit()
|
|
await db.refresh(domain)
|
|
|
|
return PortfolioDomainResponse(
|
|
id=domain.id,
|
|
domain=domain.domain,
|
|
purchase_date=domain.purchase_date,
|
|
purchase_price=domain.purchase_price,
|
|
purchase_registrar=domain.purchase_registrar,
|
|
registrar=domain.registrar,
|
|
renewal_date=domain.renewal_date,
|
|
renewal_cost=domain.renewal_cost,
|
|
auto_renew=domain.auto_renew,
|
|
estimated_value=domain.estimated_value,
|
|
value_updated_at=domain.value_updated_at,
|
|
is_sold=domain.is_sold,
|
|
sale_date=domain.sale_date,
|
|
sale_price=domain.sale_price,
|
|
status=domain.status,
|
|
notes=domain.notes,
|
|
tags=domain.tags,
|
|
roi=domain.roi,
|
|
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
|
|
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
|
|
verification_code=getattr(domain, 'verification_code', None),
|
|
verified_at=getattr(domain, 'verified_at', None),
|
|
created_at=domain.created_at,
|
|
updated_at=domain.updated_at,
|
|
)
|
|
|
|
|
|
@router.post("/{domain_id}/sell", response_model=PortfolioDomainResponse)
|
|
async def mark_domain_sold(
|
|
domain_id: int,
|
|
data: PortfolioDomainSell,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Mark a domain as sold."""
|
|
result = await db.execute(
|
|
select(PortfolioDomain).where(
|
|
and_(
|
|
PortfolioDomain.id == domain_id,
|
|
PortfolioDomain.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
domain = result.scalar_one_or_none()
|
|
|
|
if not domain:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Domain not found in portfolio",
|
|
)
|
|
|
|
domain.is_sold = True
|
|
domain.sale_date = data.sale_date
|
|
domain.sale_price = data.sale_price
|
|
domain.status = "sold"
|
|
|
|
await db.commit()
|
|
await db.refresh(domain)
|
|
|
|
return PortfolioDomainResponse(
|
|
id=domain.id,
|
|
domain=domain.domain,
|
|
purchase_date=domain.purchase_date,
|
|
purchase_price=domain.purchase_price,
|
|
purchase_registrar=domain.purchase_registrar,
|
|
registrar=domain.registrar,
|
|
renewal_date=domain.renewal_date,
|
|
renewal_cost=domain.renewal_cost,
|
|
auto_renew=domain.auto_renew,
|
|
estimated_value=domain.estimated_value,
|
|
value_updated_at=domain.value_updated_at,
|
|
is_sold=domain.is_sold,
|
|
sale_date=domain.sale_date,
|
|
sale_price=domain.sale_price,
|
|
status=domain.status,
|
|
notes=domain.notes,
|
|
tags=domain.tags,
|
|
roi=domain.roi,
|
|
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
|
|
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
|
|
verification_code=getattr(domain, 'verification_code', None),
|
|
verified_at=getattr(domain, 'verified_at', None),
|
|
created_at=domain.created_at,
|
|
updated_at=domain.updated_at,
|
|
)
|
|
|
|
|
|
@router.delete("/{domain_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_portfolio_domain(
|
|
domain_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete a domain from portfolio."""
|
|
result = await db.execute(
|
|
select(PortfolioDomain).where(
|
|
and_(
|
|
PortfolioDomain.id == domain_id,
|
|
PortfolioDomain.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
domain = result.scalar_one_or_none()
|
|
|
|
if not domain:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Domain not found in portfolio",
|
|
)
|
|
|
|
await db.delete(domain)
|
|
await db.commit()
|
|
|
|
|
|
@router.post("/{domain_id}/refresh-value", response_model=PortfolioDomainResponse)
|
|
async def refresh_domain_value(
|
|
domain_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Refresh the estimated value of a portfolio domain."""
|
|
result = await db.execute(
|
|
select(PortfolioDomain).where(
|
|
and_(
|
|
PortfolioDomain.id == domain_id,
|
|
PortfolioDomain.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
domain = result.scalar_one_or_none()
|
|
|
|
if not domain:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Domain not found in portfolio",
|
|
)
|
|
|
|
# Get new valuation
|
|
valuation = await valuation_service.estimate_value(domain.domain, db, save_result=True)
|
|
|
|
if "error" not in valuation:
|
|
domain.estimated_value = valuation["estimated_value"]
|
|
domain.value_updated_at = datetime.utcnow()
|
|
await db.commit()
|
|
await db.refresh(domain)
|
|
|
|
return PortfolioDomainResponse(
|
|
id=domain.id,
|
|
domain=domain.domain,
|
|
purchase_date=domain.purchase_date,
|
|
purchase_price=domain.purchase_price,
|
|
purchase_registrar=domain.purchase_registrar,
|
|
registrar=domain.registrar,
|
|
renewal_date=domain.renewal_date,
|
|
renewal_cost=domain.renewal_cost,
|
|
auto_renew=domain.auto_renew,
|
|
estimated_value=domain.estimated_value,
|
|
value_updated_at=domain.value_updated_at,
|
|
is_sold=domain.is_sold,
|
|
sale_date=domain.sale_date,
|
|
sale_price=domain.sale_price,
|
|
status=domain.status,
|
|
notes=domain.notes,
|
|
tags=domain.tags,
|
|
roi=domain.roi,
|
|
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
|
|
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
|
|
verification_code=getattr(domain, 'verification_code', None),
|
|
verified_at=getattr(domain, 'verified_at', None),
|
|
created_at=domain.created_at,
|
|
updated_at=domain.updated_at,
|
|
)
|
|
|
|
|
|
# ============== Valuation Endpoints ==============
|
|
|
|
@router.get("/valuation/{domain}", response_model=ValuationResponse)
|
|
async def get_domain_valuation(
|
|
domain: str,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get estimated value for any domain."""
|
|
valuation = await valuation_service.estimate_value(domain, db, save_result=True)
|
|
|
|
if "error" in valuation:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=valuation["error"],
|
|
)
|
|
|
|
return ValuationResponse(**valuation)
|
|
|
|
|
|
# ============== DNS Verification Endpoints ==============
|
|
|
|
def _generate_verification_code() -> str:
|
|
"""Generate a unique verification code."""
|
|
return f"pounce-verify-{secrets.token_hex(8)}"
|
|
|
|
|
|
def _domain_to_response(domain: PortfolioDomain) -> PortfolioDomainResponse:
|
|
"""Convert PortfolioDomain to response schema."""
|
|
return PortfolioDomainResponse(
|
|
id=domain.id,
|
|
domain=domain.domain,
|
|
purchase_date=domain.purchase_date,
|
|
purchase_price=domain.purchase_price,
|
|
purchase_registrar=domain.purchase_registrar,
|
|
registrar=domain.registrar,
|
|
renewal_date=domain.renewal_date,
|
|
renewal_cost=domain.renewal_cost,
|
|
auto_renew=domain.auto_renew,
|
|
estimated_value=domain.estimated_value,
|
|
value_updated_at=domain.value_updated_at,
|
|
is_sold=domain.is_sold,
|
|
sale_date=domain.sale_date,
|
|
sale_price=domain.sale_price,
|
|
status=domain.status,
|
|
notes=domain.notes,
|
|
tags=domain.tags,
|
|
roi=domain.roi,
|
|
# Use getattr with defaults for new fields that may not exist in DB yet
|
|
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
|
|
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
|
|
verification_code=getattr(domain, 'verification_code', None),
|
|
verified_at=getattr(domain, 'verified_at', None),
|
|
created_at=domain.created_at,
|
|
updated_at=domain.updated_at,
|
|
)
|
|
|
|
|
|
@router.post("/{domain_id}/verify-dns", response_model=DNSVerificationStartResponse)
|
|
async def start_dns_verification(
|
|
domain_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Start DNS verification for a portfolio domain.
|
|
|
|
Returns a verification code that must be added as a TXT record.
|
|
"""
|
|
result = await db.execute(
|
|
select(PortfolioDomain).where(
|
|
and_(
|
|
PortfolioDomain.id == domain_id,
|
|
PortfolioDomain.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
domain = result.scalar_one_or_none()
|
|
|
|
if not domain:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Domain not found in portfolio",
|
|
)
|
|
|
|
if domain.is_dns_verified:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Domain is already verified",
|
|
)
|
|
|
|
# Generate or reuse existing verification code
|
|
if not domain.verification_code:
|
|
domain.verification_code = _generate_verification_code()
|
|
|
|
domain.verification_status = "pending"
|
|
domain.verification_started_at = datetime.utcnow()
|
|
|
|
await db.commit()
|
|
await db.refresh(domain)
|
|
|
|
return DNSVerificationStartResponse(
|
|
domain_id=domain.id,
|
|
domain=domain.domain,
|
|
verification_code=domain.verification_code,
|
|
dns_record_type="TXT",
|
|
dns_record_name=f"_pounce.{domain.domain}",
|
|
dns_record_value=domain.verification_code,
|
|
instructions=f"Add a TXT record to your DNS settings:\n\nHost/Name: _pounce\nType: TXT\nValue: {domain.verification_code}\n\nDNS changes can take up to 48 hours to propagate, but usually complete within minutes.",
|
|
status=domain.verification_status,
|
|
)
|
|
|
|
|
|
@router.get("/{domain_id}/verify-dns/check", response_model=DNSVerificationCheckResponse)
|
|
async def check_dns_verification(
|
|
domain_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Check if DNS verification is complete.
|
|
|
|
Looks for the TXT record and verifies it matches the expected code.
|
|
"""
|
|
result = await db.execute(
|
|
select(PortfolioDomain).where(
|
|
and_(
|
|
PortfolioDomain.id == domain_id,
|
|
PortfolioDomain.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
domain = result.scalar_one_or_none()
|
|
|
|
if not domain:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Domain not found in portfolio",
|
|
)
|
|
|
|
if domain.is_dns_verified:
|
|
return DNSVerificationCheckResponse(
|
|
verified=True,
|
|
status="verified",
|
|
message="Domain ownership already verified",
|
|
)
|
|
|
|
if not domain.verification_code:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Verification not started. Call POST /verify-dns first.",
|
|
)
|
|
|
|
# Check DNS TXT record
|
|
txt_record_name = f"_pounce.{domain.domain}"
|
|
verified = False
|
|
|
|
try:
|
|
resolver = dns.resolver.Resolver()
|
|
resolver.timeout = 5
|
|
resolver.lifetime = 10
|
|
|
|
answers = resolver.resolve(txt_record_name, 'TXT')
|
|
|
|
for rdata in answers:
|
|
txt_value = rdata.to_text().strip('"')
|
|
if txt_value == domain.verification_code:
|
|
verified = True
|
|
break
|
|
except dns.resolver.NXDOMAIN:
|
|
return DNSVerificationCheckResponse(
|
|
verified=False,
|
|
status="pending",
|
|
message=f"TXT record not found. Please add a TXT record at _pounce.{domain.domain}",
|
|
)
|
|
except dns.resolver.NoAnswer:
|
|
return DNSVerificationCheckResponse(
|
|
verified=False,
|
|
status="pending",
|
|
message="TXT record exists but has no value. Check your DNS configuration.",
|
|
)
|
|
except dns.resolver.Timeout:
|
|
return DNSVerificationCheckResponse(
|
|
verified=False,
|
|
status="pending",
|
|
message="DNS query timed out. Please try again.",
|
|
)
|
|
except Exception as e:
|
|
return DNSVerificationCheckResponse(
|
|
verified=False,
|
|
status="error",
|
|
message=f"DNS lookup error: {str(e)}",
|
|
)
|
|
|
|
if verified:
|
|
domain.is_dns_verified = True
|
|
domain.verification_status = "verified"
|
|
domain.verified_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
return DNSVerificationCheckResponse(
|
|
verified=True,
|
|
status="verified",
|
|
message="Domain ownership verified successfully! You can now list this domain for sale or activate Yield.",
|
|
)
|
|
else:
|
|
return DNSVerificationCheckResponse(
|
|
verified=False,
|
|
status="pending",
|
|
message=f"TXT record found but value doesn't match. Expected: {domain.verification_code}",
|
|
)
|
|
|
|
|
|
@router.get("/verified", response_model=List[PortfolioDomainResponse])
|
|
async def get_verified_domains(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Get only DNS-verified portfolio domains.
|
|
|
|
These domains can be used for Yield or For Sale listings.
|
|
"""
|
|
result = await db.execute(
|
|
select(PortfolioDomain).where(
|
|
and_(
|
|
PortfolioDomain.user_id == current_user.id,
|
|
PortfolioDomain.is_dns_verified == True,
|
|
PortfolioDomain.is_sold == False,
|
|
)
|
|
).order_by(PortfolioDomain.domain.asc())
|
|
)
|
|
domains = result.scalars().all()
|
|
|
|
return [_domain_to_response(d) for d in domains]
|
|
|