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
1477 lines
49 KiB
Python
1477 lines
49 KiB
Python
"""
|
|
Domain Listings API - Pounce Marketplace
|
|
|
|
This implements the "Micro-Marktplatz" from analysis_3.md:
|
|
- Create professional "For Sale" landing pages
|
|
- DNS verification for ownership
|
|
- Contact form for buyers
|
|
- Analytics
|
|
|
|
Endpoints:
|
|
- GET /listings - Public: Browse active listings
|
|
- GET /listings/{slug} - Public: View listing details
|
|
- POST /listings/{slug}/inquire - Public: Contact seller
|
|
- POST /listings - Auth: Create new listing
|
|
- GET /listings/my - Auth: Get user's listings
|
|
- PUT /listings/{id} - Auth: Update listing
|
|
- DELETE /listings/{id} - Auth: Delete listing
|
|
- POST /listings/{id}/verify-dns - Auth: Start DNS verification
|
|
- GET /listings/{id}/verify-dns/check - Auth: Check verification status
|
|
"""
|
|
import logging
|
|
import secrets
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional, List
|
|
from fastapi import APIRouter, Depends, Query, HTTPException, Request
|
|
from pydantic import BaseModel, Field, EmailStr
|
|
from sqlalchemy import select, func, and_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.database import get_db
|
|
from app.api.deps import get_current_user, get_current_user_optional
|
|
from app.models.user import User
|
|
from app.models.listing import (
|
|
DomainListing,
|
|
ListingInquiry,
|
|
ListingInquiryEvent,
|
|
ListingInquiryMessage,
|
|
ListingView,
|
|
ListingStatus,
|
|
VerificationStatus,
|
|
)
|
|
from app.services.valuation import valuation_service
|
|
from app.services.telemetry import track_event
|
|
|
|
|
|
def _calculate_pounce_score(domain: str, is_pounce: bool = True) -> int:
|
|
"""
|
|
Calculate Pounce Score for a domain.
|
|
Uses the same algorithm as Market Feed (_calculate_pounce_score_v2 in auctions.py).
|
|
"""
|
|
# Parse domain
|
|
parts = domain.lower().rsplit(".", 1)
|
|
if len(parts) != 2:
|
|
return 50
|
|
|
|
name, tld = parts
|
|
score = 50 # Baseline
|
|
|
|
# A) LENGTH BONUS (exponential for short domains)
|
|
length_scores = {1: 50, 2: 45, 3: 40, 4: 30, 5: 20, 6: 15, 7: 10}
|
|
score += length_scores.get(len(name), max(0, 15 - len(name)))
|
|
|
|
# B) TLD PREMIUM
|
|
tld_scores = {
|
|
'com': 20, 'ai': 25, 'io': 18, 'co': 12,
|
|
'ch': 15, 'de': 10, 'net': 8, 'org': 8,
|
|
'app': 10, 'dev': 10, 'xyz': 5
|
|
}
|
|
score += tld_scores.get(tld.lower(), 0)
|
|
|
|
# C) POUNCE DIRECT BONUS (listings are always Pounce Direct)
|
|
if is_pounce:
|
|
score += 10
|
|
|
|
# D) PENALTIES
|
|
if '-' in name:
|
|
score -= 25
|
|
if any(c.isdigit() for c in name) and len(name) > 3:
|
|
score -= 20
|
|
if len(name) > 15:
|
|
score -= 15
|
|
|
|
# Clamp to 0-100
|
|
return max(0, min(100, score))
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
|
|
# ============== Schemas ==============
|
|
|
|
class ListingCreate(BaseModel):
|
|
"""Create a new domain listing."""
|
|
domain: str = Field(..., min_length=3, max_length=255)
|
|
title: Optional[str] = Field(None, max_length=200)
|
|
description: Optional[str] = None
|
|
asking_price: Optional[float] = Field(None, ge=0)
|
|
min_offer: Optional[float] = Field(None, ge=0)
|
|
currency: str = Field("USD", max_length=3)
|
|
price_type: str = Field("negotiable") # fixed, negotiable, make_offer
|
|
show_valuation: bool = True
|
|
allow_offers: bool = True
|
|
|
|
|
|
class ListingUpdate(BaseModel):
|
|
"""Update a listing."""
|
|
title: Optional[str] = Field(None, max_length=200)
|
|
description: Optional[str] = None
|
|
asking_price: Optional[float] = Field(None, ge=0)
|
|
min_offer: Optional[float] = Field(None, ge=0)
|
|
price_type: Optional[str] = None
|
|
show_valuation: Optional[bool] = None
|
|
allow_offers: Optional[bool] = None
|
|
status: Optional[str] = None
|
|
sold_reason: Optional[str] = Field(None, max_length=200)
|
|
sold_price: Optional[float] = Field(None, ge=0)
|
|
sold_currency: Optional[str] = Field(None, max_length=3)
|
|
|
|
|
|
class ListingResponse(BaseModel):
|
|
"""Listing response."""
|
|
id: int
|
|
domain: str
|
|
slug: str
|
|
title: Optional[str]
|
|
description: Optional[str]
|
|
asking_price: Optional[float]
|
|
min_offer: Optional[float]
|
|
currency: str
|
|
price_type: str
|
|
pounce_score: Optional[int]
|
|
estimated_value: Optional[float]
|
|
verification_status: str
|
|
is_verified: bool
|
|
status: str
|
|
show_valuation: bool
|
|
allow_offers: bool
|
|
view_count: int
|
|
inquiry_count: int
|
|
public_url: str
|
|
created_at: datetime
|
|
published_at: Optional[datetime]
|
|
sold_at: Optional[datetime] = None
|
|
sold_reason: Optional[str] = None
|
|
sold_price: Optional[float] = None
|
|
sold_currency: Optional[str] = None
|
|
|
|
# Seller info (minimal for privacy)
|
|
seller_verified: bool = False
|
|
seller_member_since: Optional[datetime] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class ListingPublicResponse(BaseModel):
|
|
"""Public listing response (limited info)."""
|
|
domain: str
|
|
slug: str
|
|
title: Optional[str]
|
|
description: Optional[str]
|
|
asking_price: Optional[float]
|
|
currency: str
|
|
price_type: str
|
|
pounce_score: Optional[int]
|
|
estimated_value: Optional[float]
|
|
is_verified: bool
|
|
allow_offers: bool
|
|
public_url: str
|
|
|
|
# Seller trust indicators
|
|
seller_verified: bool
|
|
seller_member_since: Optional[datetime]
|
|
seller_invite_code: Optional[str] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class InquiryCreate(BaseModel):
|
|
"""Create an inquiry for a listing."""
|
|
name: str = Field(..., min_length=2, max_length=100)
|
|
email: EmailStr
|
|
phone: Optional[str] = Field(None, max_length=50)
|
|
company: Optional[str] = Field(None, max_length=200)
|
|
message: str = Field(..., min_length=10, max_length=2000)
|
|
offer_amount: Optional[float] = Field(None, ge=0)
|
|
|
|
|
|
class InquiryResponse(BaseModel):
|
|
"""Inquiry response for listing owner."""
|
|
id: int
|
|
name: str
|
|
email: str
|
|
phone: Optional[str]
|
|
company: Optional[str]
|
|
message: str
|
|
offer_amount: Optional[float]
|
|
status: str
|
|
created_at: datetime
|
|
read_at: Optional[datetime]
|
|
replied_at: Optional[datetime] = None
|
|
closed_at: Optional[datetime] = None
|
|
closed_reason: Optional[str] = None
|
|
buyer_user_id: Optional[int] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class InquiryUpdate(BaseModel):
|
|
"""Update inquiry status for listing owner."""
|
|
status: str = Field(..., min_length=1, max_length=20) # new, read, replied, spam
|
|
reason: Optional[str] = Field(None, max_length=200)
|
|
|
|
|
|
class InquiryMessageCreate(BaseModel):
|
|
body: str = Field(..., min_length=1, max_length=4000)
|
|
|
|
|
|
class InquiryMessageResponse(BaseModel):
|
|
id: int
|
|
inquiry_id: int
|
|
listing_id: int
|
|
sender_user_id: int
|
|
body: str
|
|
created_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class VerificationResponse(BaseModel):
|
|
"""DNS verification response."""
|
|
verification_code: str
|
|
dns_record_type: str = "TXT"
|
|
dns_record_name: str
|
|
dns_record_value: str
|
|
instructions: str
|
|
status: str
|
|
|
|
|
|
# ============== Helper Functions ==============
|
|
|
|
def _generate_slug(domain: str) -> str:
|
|
"""Generate URL-friendly slug from domain."""
|
|
# Remove TLD for cleaner slug
|
|
slug = domain.lower().replace('.', '-')
|
|
# Remove any non-alphanumeric chars except hyphens
|
|
slug = re.sub(r'[^a-z0-9-]', '', slug)
|
|
return slug
|
|
|
|
|
|
def _generate_verification_code() -> str:
|
|
"""Generate a unique verification code."""
|
|
return f"pounce-verify-{secrets.token_hex(16)}"
|
|
|
|
|
|
# Security: Block phishing keywords (from analysis_3.md - Säule 3)
|
|
BLOCKED_KEYWORDS = [
|
|
'login', 'bank', 'verify', 'paypal', 'password', 'account',
|
|
'credit', 'social security', 'ssn', 'wire', 'transfer'
|
|
]
|
|
|
|
|
|
def _check_content_safety(text: str) -> bool:
|
|
"""Check if content contains phishing keywords."""
|
|
text_lower = text.lower()
|
|
return not any(keyword in text_lower for keyword in BLOCKED_KEYWORDS)
|
|
|
|
|
|
# ============== Public Endpoints ==============
|
|
|
|
@router.get("", response_model=List[ListingPublicResponse])
|
|
async def browse_listings(
|
|
keyword: Optional[str] = Query(None),
|
|
min_price: Optional[float] = Query(None, ge=0),
|
|
max_price: Optional[float] = Query(None, ge=0),
|
|
verified_only: bool = Query(False),
|
|
clean_only: bool = Query(True, description="Hide low-quality/spam listings"),
|
|
sort_by: str = Query("newest", enum=["newest", "price_asc", "price_desc", "popular"]),
|
|
limit: int = Query(20, le=50),
|
|
offset: int = Query(0, ge=0),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Browse active domain listings (public)."""
|
|
query = select(DomainListing).options(selectinload(DomainListing.user)).where(
|
|
DomainListing.status == ListingStatus.ACTIVE.value
|
|
)
|
|
|
|
if keyword:
|
|
query = query.where(DomainListing.domain.ilike(f"%{keyword}%"))
|
|
|
|
if min_price is not None:
|
|
query = query.where(DomainListing.asking_price >= min_price)
|
|
|
|
if max_price is not None:
|
|
query = query.where(DomainListing.asking_price <= max_price)
|
|
|
|
if verified_only:
|
|
query = query.where(
|
|
DomainListing.verification_status == VerificationStatus.VERIFIED.value
|
|
)
|
|
|
|
# Sorting
|
|
if sort_by == "price_asc":
|
|
query = query.order_by(DomainListing.asking_price.asc().nullslast())
|
|
elif sort_by == "price_desc":
|
|
query = query.order_by(DomainListing.asking_price.desc().nullsfirst())
|
|
elif sort_by == "popular":
|
|
query = query.order_by(DomainListing.view_count.desc())
|
|
else: # newest
|
|
query = query.order_by(DomainListing.published_at.desc())
|
|
|
|
query = query.offset(offset).limit(limit)
|
|
|
|
result = await db.execute(query)
|
|
listings = list(result.scalars().all())
|
|
|
|
responses = []
|
|
for listing in listings:
|
|
# Calculate pounce_score dynamically if not stored
|
|
pounce_score = listing.pounce_score
|
|
if pounce_score is None:
|
|
pounce_score = _calculate_pounce_score(listing.domain)
|
|
# Save it for future requests
|
|
listing.pounce_score = pounce_score
|
|
|
|
# Public cleanliness rule: don't surface low-quality inventory by default.
|
|
# (Still accessible in Terminal for authenticated power users.)
|
|
if clean_only and (pounce_score or 0) < 50:
|
|
continue
|
|
|
|
responses.append(ListingPublicResponse(
|
|
domain=listing.domain,
|
|
slug=listing.slug,
|
|
title=listing.title,
|
|
description=listing.description,
|
|
asking_price=listing.asking_price,
|
|
currency=listing.currency,
|
|
price_type=listing.price_type,
|
|
pounce_score=pounce_score, # Always return the score
|
|
estimated_value=listing.estimated_value if listing.show_valuation else None,
|
|
is_verified=listing.is_verified,
|
|
allow_offers=listing.allow_offers,
|
|
public_url=listing.public_url,
|
|
seller_verified=listing.is_verified,
|
|
seller_member_since=listing.user.created_at if listing.user else None,
|
|
seller_invite_code=getattr(listing.user, "invite_code", None) if listing.user else None,
|
|
))
|
|
|
|
await db.commit() # Save any updated pounce_scores
|
|
return responses
|
|
|
|
|
|
# ============== Authenticated Endpoints (before dynamic routes!) ==============
|
|
|
|
@router.get("/my", response_model=List[ListingResponse])
|
|
async def get_my_listings(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get current user's listings."""
|
|
result = await db.execute(
|
|
select(DomainListing)
|
|
.where(DomainListing.user_id == current_user.id)
|
|
.order_by(DomainListing.created_at.desc())
|
|
)
|
|
listings = list(result.scalars().all())
|
|
|
|
return [
|
|
ListingResponse(
|
|
id=listing.id,
|
|
domain=listing.domain,
|
|
slug=listing.slug,
|
|
title=listing.title,
|
|
description=listing.description,
|
|
asking_price=listing.asking_price,
|
|
min_offer=listing.min_offer,
|
|
currency=listing.currency,
|
|
price_type=listing.price_type,
|
|
pounce_score=listing.pounce_score,
|
|
estimated_value=listing.estimated_value,
|
|
verification_status=listing.verification_status,
|
|
is_verified=listing.is_verified,
|
|
status=listing.status,
|
|
show_valuation=listing.show_valuation,
|
|
allow_offers=listing.allow_offers,
|
|
view_count=listing.view_count,
|
|
inquiry_count=listing.inquiry_count,
|
|
public_url=listing.public_url,
|
|
created_at=listing.created_at,
|
|
published_at=listing.published_at,
|
|
sold_at=getattr(listing, "sold_at", None),
|
|
sold_reason=getattr(listing, "sold_reason", None),
|
|
sold_price=getattr(listing, "sold_price", None),
|
|
sold_currency=getattr(listing, "sold_currency", None),
|
|
seller_verified=current_user.is_verified,
|
|
seller_member_since=current_user.created_at,
|
|
)
|
|
for listing in listings
|
|
]
|
|
|
|
|
|
# ============== Public Dynamic Routes ==============
|
|
|
|
@router.get("/{slug}", response_model=ListingPublicResponse)
|
|
async def get_listing_by_slug(
|
|
slug: str,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: Optional[User] = Depends(get_current_user_optional),
|
|
):
|
|
"""Get listing details by slug (public)."""
|
|
result = await db.execute(
|
|
select(DomainListing)
|
|
.options(selectinload(DomainListing.user))
|
|
.where(
|
|
and_(
|
|
DomainListing.slug == slug,
|
|
DomainListing.status == ListingStatus.ACTIVE.value,
|
|
)
|
|
)
|
|
)
|
|
listing = result.scalar_one_or_none()
|
|
|
|
if not listing:
|
|
raise HTTPException(status_code=404, detail="Listing not found")
|
|
|
|
# Record view
|
|
view = ListingView(
|
|
listing_id=listing.id,
|
|
ip_address=request.client.host if request.client else None,
|
|
user_agent=request.headers.get("user-agent", "")[:500],
|
|
referrer=request.headers.get("referer", "")[:500],
|
|
user_id=current_user.id if current_user else None,
|
|
)
|
|
db.add(view)
|
|
|
|
# Increment view count
|
|
listing.view_count += 1
|
|
|
|
await track_event(
|
|
db,
|
|
event_name="listing_view",
|
|
request=request,
|
|
user_id=current_user.id if current_user else None,
|
|
is_authenticated=bool(current_user),
|
|
source="public",
|
|
domain=listing.domain,
|
|
listing_id=listing.id,
|
|
metadata={"slug": listing.slug},
|
|
)
|
|
|
|
# Calculate pounce_score dynamically if not stored (same as Market Feed)
|
|
pounce_score = listing.pounce_score
|
|
if pounce_score is None:
|
|
pounce_score = _calculate_pounce_score(listing.domain)
|
|
# Save it for future requests
|
|
listing.pounce_score = pounce_score
|
|
|
|
await db.commit()
|
|
|
|
return ListingPublicResponse(
|
|
domain=listing.domain,
|
|
slug=listing.slug,
|
|
title=listing.title,
|
|
description=listing.description,
|
|
asking_price=listing.asking_price,
|
|
currency=listing.currency,
|
|
price_type=listing.price_type,
|
|
pounce_score=pounce_score, # Always return the score
|
|
estimated_value=listing.estimated_value if listing.show_valuation else None,
|
|
is_verified=listing.is_verified,
|
|
allow_offers=listing.allow_offers,
|
|
public_url=listing.public_url,
|
|
seller_verified=listing.is_verified,
|
|
seller_member_since=listing.user.created_at if listing.user else None,
|
|
seller_invite_code=getattr(listing.user, "invite_code", None) if listing.user else None,
|
|
)
|
|
|
|
|
|
@router.post("/{slug}/inquire")
|
|
async def submit_inquiry(
|
|
slug: str,
|
|
inquiry: InquiryCreate,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Submit an inquiry for a listing (requires authentication)."""
|
|
# Find listing
|
|
result = await db.execute(
|
|
select(DomainListing).where(
|
|
and_(
|
|
DomainListing.slug == slug,
|
|
DomainListing.status == ListingStatus.ACTIVE.value,
|
|
)
|
|
)
|
|
)
|
|
listing = result.scalar_one_or_none()
|
|
|
|
if not listing:
|
|
raise HTTPException(status_code=404, detail="Listing not found")
|
|
|
|
# Require that inquiries are sent from the authenticated account email.
|
|
# This prevents anonymous spam and makes the buyer identity consistent.
|
|
if inquiry.email.lower() != (current_user.email or "").lower():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Inquiry email must match your account email.",
|
|
)
|
|
|
|
# Security: Check for phishing keywords
|
|
if not _check_content_safety(inquiry.message):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Message contains blocked content. Please revise."
|
|
)
|
|
|
|
# Rate limiting check (simple: max 3 inquiries per user per listing per day)
|
|
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
existing_count = await db.execute(
|
|
select(func.count(ListingInquiry.id)).where(
|
|
and_(
|
|
ListingInquiry.listing_id == listing.id,
|
|
ListingInquiry.buyer_user_id == current_user.id,
|
|
ListingInquiry.created_at >= today_start,
|
|
)
|
|
)
|
|
)
|
|
if existing_count.scalar() >= 3:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail="Too many inquiries. Please try again tomorrow."
|
|
)
|
|
|
|
# Create inquiry
|
|
new_inquiry = ListingInquiry(
|
|
listing_id=listing.id,
|
|
buyer_user_id=current_user.id,
|
|
name=inquiry.name,
|
|
email=inquiry.email.lower(),
|
|
phone=inquiry.phone,
|
|
company=inquiry.company,
|
|
message=inquiry.message,
|
|
offer_amount=inquiry.offer_amount,
|
|
ip_address=request.client.host if request.client else None,
|
|
user_agent=request.headers.get("user-agent", "")[:500],
|
|
)
|
|
db.add(new_inquiry)
|
|
await db.flush()
|
|
|
|
await track_event(
|
|
db,
|
|
event_name="inquiry_created",
|
|
request=request,
|
|
user_id=current_user.id,
|
|
is_authenticated=True,
|
|
source="public",
|
|
domain=listing.domain,
|
|
listing_id=listing.id,
|
|
inquiry_id=new_inquiry.id,
|
|
metadata={
|
|
"offer_amount": inquiry.offer_amount,
|
|
"has_phone": bool(inquiry.phone),
|
|
"has_company": bool(inquiry.company),
|
|
},
|
|
)
|
|
|
|
# Seed thread with the initial message
|
|
db.add(
|
|
ListingInquiryMessage(
|
|
inquiry_id=new_inquiry.id,
|
|
listing_id=listing.id,
|
|
sender_user_id=current_user.id,
|
|
body=inquiry.message,
|
|
)
|
|
)
|
|
|
|
# Increment inquiry count
|
|
listing.inquiry_count += 1
|
|
|
|
await db.commit()
|
|
|
|
# Send email notification to seller
|
|
try:
|
|
from app.services.email_service import email_service
|
|
from app.models.user import User
|
|
|
|
# Get seller's email
|
|
seller_result = await db.execute(
|
|
select(User).where(User.id == listing.user_id)
|
|
)
|
|
seller = seller_result.scalar_one_or_none()
|
|
|
|
if seller and seller.email and email_service.is_configured():
|
|
await email_service.send_listing_inquiry(
|
|
to_email=seller.email,
|
|
domain=listing.domain,
|
|
name=inquiry.name,
|
|
email=inquiry.email,
|
|
message=inquiry.message,
|
|
company=inquiry.company,
|
|
offer_amount=inquiry.offer_amount,
|
|
)
|
|
logger.info(f"📧 Inquiry notification sent to {seller.email} for {listing.domain}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send inquiry notification: {e}")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Your inquiry has been sent to the seller.",
|
|
}
|
|
|
|
|
|
# ============== Listing Management (Authenticated) ==============
|
|
|
|
@router.post("", response_model=ListingResponse)
|
|
async def create_listing(
|
|
data: ListingCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Create a new domain listing.
|
|
|
|
SECURITY: Domain must be in user's portfolio before listing for sale.
|
|
DNS verification happens in the verification step (separate endpoint).
|
|
"""
|
|
from app.models.portfolio import PortfolioDomain
|
|
|
|
domain_lower = data.domain.lower()
|
|
|
|
# SECURITY CHECK: Domain must be in user's portfolio
|
|
portfolio_result = await db.execute(
|
|
select(PortfolioDomain).where(
|
|
PortfolioDomain.domain == domain_lower,
|
|
PortfolioDomain.user_id == current_user.id,
|
|
)
|
|
)
|
|
portfolio_domain = portfolio_result.scalar_one_or_none()
|
|
|
|
if not portfolio_domain:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Domain must be in your portfolio before listing for sale. Add it to your portfolio first.",
|
|
)
|
|
|
|
# Check if domain is sold
|
|
if portfolio_domain.is_sold:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Cannot list a sold domain for sale.",
|
|
)
|
|
|
|
# Check if domain is DNS verified in portfolio
|
|
# If verified in portfolio, listing inherits verification
|
|
is_portfolio_verified = getattr(portfolio_domain, 'is_dns_verified', False) or False
|
|
|
|
# Check if domain is already listed
|
|
existing = await db.execute(
|
|
select(DomainListing).where(DomainListing.domain == domain_lower)
|
|
)
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(status_code=400, detail="This domain is already listed")
|
|
|
|
# Check user's listing limit based on subscription
|
|
user_listings = await db.execute(
|
|
select(func.count(DomainListing.id)).where(
|
|
DomainListing.user_id == current_user.id
|
|
)
|
|
)
|
|
listing_count = user_listings.scalar() or 0
|
|
|
|
# Listing limits by tier (from pounce_pricing.md)
|
|
# Load subscription separately to avoid async lazy loading issues
|
|
from app.models.subscription import Subscription
|
|
sub_result = await db.execute(
|
|
select(Subscription).where(Subscription.user_id == current_user.id)
|
|
)
|
|
subscription = sub_result.scalar_one_or_none()
|
|
tier = subscription.tier if subscription else "scout"
|
|
limits = {"scout": 0, "trader": 5, "tycoon": 50}
|
|
max_listings = limits.get(tier, 0)
|
|
|
|
if listing_count >= max_listings:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"Listing limit reached ({max_listings}). Upgrade your plan for more."
|
|
)
|
|
|
|
# Generate slug
|
|
slug = _generate_slug(domain_lower)
|
|
|
|
# Check slug uniqueness
|
|
slug_check = await db.execute(
|
|
select(DomainListing).where(DomainListing.slug == slug)
|
|
)
|
|
if slug_check.scalar_one_or_none():
|
|
slug = f"{slug}-{secrets.token_hex(4)}"
|
|
|
|
# Get valuation
|
|
try:
|
|
valuation = await valuation_service.estimate_value(domain_lower, db, save_result=False)
|
|
pounce_score = min(100, int(valuation.get("score", 50)))
|
|
estimated_value = valuation.get("value", 0) # Fixed: was 'estimated_value', service returns 'value'
|
|
except Exception:
|
|
pounce_score = 50
|
|
estimated_value = None
|
|
|
|
# Create listing
|
|
# If portfolio domain is already DNS verified, listing is auto-verified
|
|
listing = DomainListing(
|
|
user_id=current_user.id,
|
|
domain=domain_lower,
|
|
slug=slug,
|
|
title=data.title,
|
|
description=data.description,
|
|
asking_price=data.asking_price,
|
|
min_offer=data.min_offer,
|
|
currency=data.currency.upper(),
|
|
price_type=data.price_type,
|
|
show_valuation=data.show_valuation,
|
|
allow_offers=data.allow_offers,
|
|
pounce_score=pounce_score,
|
|
estimated_value=estimated_value,
|
|
verification_code=portfolio_domain.verification_code if is_portfolio_verified else _generate_verification_code(),
|
|
verification_status=VerificationStatus.VERIFIED.value if is_portfolio_verified else VerificationStatus.PENDING.value,
|
|
verified_at=portfolio_domain.verified_at if is_portfolio_verified else None,
|
|
status=ListingStatus.DRAFT.value,
|
|
)
|
|
|
|
db.add(listing)
|
|
await db.commit()
|
|
await db.refresh(listing)
|
|
|
|
return ListingResponse(
|
|
id=listing.id,
|
|
domain=listing.domain,
|
|
slug=listing.slug,
|
|
title=listing.title,
|
|
description=listing.description,
|
|
asking_price=listing.asking_price,
|
|
min_offer=listing.min_offer,
|
|
currency=listing.currency,
|
|
price_type=listing.price_type,
|
|
pounce_score=listing.pounce_score,
|
|
estimated_value=listing.estimated_value,
|
|
verification_status=listing.verification_status,
|
|
is_verified=listing.is_verified,
|
|
status=listing.status,
|
|
show_valuation=listing.show_valuation,
|
|
allow_offers=listing.allow_offers,
|
|
view_count=listing.view_count,
|
|
inquiry_count=listing.inquiry_count,
|
|
public_url=listing.public_url,
|
|
created_at=listing.created_at,
|
|
published_at=listing.published_at,
|
|
seller_verified=current_user.is_verified,
|
|
seller_member_since=current_user.created_at,
|
|
)
|
|
|
|
|
|
@router.get("/{id}/inquiries", response_model=List[InquiryResponse])
|
|
async def get_listing_inquiries(
|
|
id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Get inquiries for a listing."""
|
|
# Verify ownership
|
|
result = await db.execute(
|
|
select(DomainListing).where(
|
|
and_(
|
|
DomainListing.id == id,
|
|
DomainListing.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
listing = result.scalar_one_or_none()
|
|
|
|
if not listing:
|
|
raise HTTPException(status_code=404, detail="Listing not found")
|
|
|
|
inquiries_result = await db.execute(
|
|
select(ListingInquiry)
|
|
.where(ListingInquiry.listing_id == id)
|
|
.order_by(ListingInquiry.created_at.desc())
|
|
)
|
|
inquiries = list(inquiries_result.scalars().all())
|
|
|
|
return [
|
|
InquiryResponse(
|
|
id=inq.id,
|
|
name=inq.name,
|
|
email=inq.email,
|
|
phone=inq.phone,
|
|
company=inq.company,
|
|
message=inq.message,
|
|
offer_amount=inq.offer_amount,
|
|
status=inq.status,
|
|
created_at=inq.created_at,
|
|
read_at=inq.read_at,
|
|
replied_at=getattr(inq, "replied_at", None),
|
|
closed_at=getattr(inq, "closed_at", None),
|
|
closed_reason=getattr(inq, "closed_reason", None),
|
|
buyer_user_id=getattr(inq, "buyer_user_id", None),
|
|
)
|
|
for inq in inquiries
|
|
]
|
|
|
|
|
|
@router.patch("/{id}/inquiries/{inquiry_id}", response_model=InquiryResponse)
|
|
async def update_listing_inquiry(
|
|
id: int,
|
|
inquiry_id: int,
|
|
data: InquiryUpdate,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update an inquiry status (listing owner only)."""
|
|
allowed = {"new", "read", "replied", "closed", "spam"}
|
|
status_clean = (data.status or "").strip().lower()
|
|
if status_clean not in allowed:
|
|
raise HTTPException(status_code=400, detail="Invalid status")
|
|
|
|
# Verify listing ownership
|
|
listing_result = await db.execute(
|
|
select(DomainListing).where(
|
|
and_(
|
|
DomainListing.id == id,
|
|
DomainListing.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
listing = listing_result.scalar_one_or_none()
|
|
if not listing:
|
|
raise HTTPException(status_code=404, detail="Listing not found")
|
|
|
|
inquiry_result = await db.execute(
|
|
select(ListingInquiry).where(
|
|
and_(
|
|
ListingInquiry.id == inquiry_id,
|
|
ListingInquiry.listing_id == id,
|
|
)
|
|
)
|
|
)
|
|
inquiry = inquiry_result.scalar_one_or_none()
|
|
if not inquiry:
|
|
raise HTTPException(status_code=404, detail="Inquiry not found")
|
|
|
|
now = datetime.utcnow()
|
|
old_status = getattr(inquiry, "status", None)
|
|
inquiry.status = status_clean
|
|
if status_clean == "read" and inquiry.read_at is None:
|
|
inquiry.read_at = now
|
|
if status_clean == "replied":
|
|
inquiry.replied_at = now
|
|
if status_clean == "closed":
|
|
inquiry.closed_at = now
|
|
inquiry.closed_reason = (data.reason or "").strip() or None
|
|
if status_clean == "spam":
|
|
inquiry.closed_reason = (data.reason or "").strip() or inquiry.closed_reason
|
|
|
|
# Audit trail
|
|
event = ListingInquiryEvent(
|
|
inquiry_id=inquiry.id,
|
|
listing_id=listing.id,
|
|
actor_user_id=current_user.id,
|
|
old_status=old_status,
|
|
new_status=status_clean,
|
|
reason=(data.reason or "").strip() or None,
|
|
ip_address=request.client.host if request.client else None,
|
|
user_agent=request.headers.get("user-agent", "")[:500],
|
|
)
|
|
db.add(event)
|
|
|
|
await track_event(
|
|
db,
|
|
event_name="inquiry_status_changed",
|
|
request=request,
|
|
user_id=current_user.id,
|
|
is_authenticated=True,
|
|
source="terminal",
|
|
domain=listing.domain,
|
|
listing_id=listing.id,
|
|
inquiry_id=inquiry.id,
|
|
metadata={"old_status": old_status, "new_status": status_clean, "reason": (data.reason or "").strip() or None},
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(inquiry)
|
|
|
|
return InquiryResponse(
|
|
id=inquiry.id,
|
|
name=inquiry.name,
|
|
email=inquiry.email,
|
|
phone=inquiry.phone,
|
|
company=inquiry.company,
|
|
message=inquiry.message,
|
|
offer_amount=inquiry.offer_amount,
|
|
status=inquiry.status,
|
|
created_at=inquiry.created_at,
|
|
read_at=inquiry.read_at,
|
|
replied_at=getattr(inquiry, "replied_at", None),
|
|
closed_at=getattr(inquiry, "closed_at", None),
|
|
closed_reason=getattr(inquiry, "closed_reason", None),
|
|
buyer_user_id=getattr(inquiry, "buyer_user_id", None),
|
|
)
|
|
|
|
|
|
@router.get("/{id}/inquiries/{inquiry_id}/messages", response_model=List[InquiryMessageResponse])
|
|
async def get_inquiry_messages_for_seller(
|
|
id: int,
|
|
inquiry_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Seller: fetch thread messages for an inquiry."""
|
|
listing_result = await db.execute(
|
|
select(DomainListing).where(and_(DomainListing.id == id, DomainListing.user_id == current_user.id))
|
|
)
|
|
listing = listing_result.scalar_one_or_none()
|
|
if not listing:
|
|
raise HTTPException(status_code=404, detail="Listing not found")
|
|
|
|
inquiry_result = await db.execute(
|
|
select(ListingInquiry).where(and_(ListingInquiry.id == inquiry_id, ListingInquiry.listing_id == id))
|
|
)
|
|
inquiry = inquiry_result.scalar_one_or_none()
|
|
if not inquiry:
|
|
raise HTTPException(status_code=404, detail="Inquiry not found")
|
|
|
|
msgs = (
|
|
await db.execute(
|
|
select(ListingInquiryMessage)
|
|
.where(and_(ListingInquiryMessage.inquiry_id == inquiry_id, ListingInquiryMessage.listing_id == id))
|
|
.order_by(ListingInquiryMessage.created_at.asc())
|
|
)
|
|
).scalars().all()
|
|
return [InquiryMessageResponse.model_validate(m) for m in msgs]
|
|
|
|
|
|
@router.post("/{id}/inquiries/{inquiry_id}/messages", response_model=InquiryMessageResponse)
|
|
async def post_inquiry_message_as_seller(
|
|
id: int,
|
|
inquiry_id: int,
|
|
payload: InquiryMessageCreate,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Seller: post a message into an inquiry thread."""
|
|
listing_result = await db.execute(
|
|
select(DomainListing).where(and_(DomainListing.id == id, DomainListing.user_id == current_user.id))
|
|
)
|
|
listing = listing_result.scalar_one_or_none()
|
|
if not listing:
|
|
raise HTTPException(status_code=404, detail="Listing not found")
|
|
|
|
inquiry_result = await db.execute(
|
|
select(ListingInquiry).where(and_(ListingInquiry.id == inquiry_id, ListingInquiry.listing_id == id))
|
|
)
|
|
inquiry = inquiry_result.scalar_one_or_none()
|
|
if not inquiry:
|
|
raise HTTPException(status_code=404, detail="Inquiry not found")
|
|
|
|
if inquiry.status in ["closed", "spam"]:
|
|
raise HTTPException(status_code=400, detail="Inquiry is closed")
|
|
|
|
# Content safety (phishing keywords)
|
|
if not _check_content_safety(payload.body):
|
|
raise HTTPException(status_code=400, detail="Message contains blocked content. Please revise.")
|
|
|
|
# Simple rate limit: max 30 messages per hour per inquiry
|
|
hour_start = datetime.utcnow() - timedelta(hours=1)
|
|
msg_count = (
|
|
await db.execute(
|
|
select(func.count(ListingInquiryMessage.id)).where(
|
|
and_(
|
|
ListingInquiryMessage.inquiry_id == inquiry.id,
|
|
ListingInquiryMessage.sender_user_id == current_user.id,
|
|
ListingInquiryMessage.created_at >= hour_start,
|
|
)
|
|
)
|
|
)
|
|
).scalar() or 0
|
|
if msg_count >= 30:
|
|
raise HTTPException(status_code=429, detail="Too many messages. Please slow down.")
|
|
|
|
msg = ListingInquiryMessage(
|
|
inquiry_id=inquiry.id,
|
|
listing_id=listing.id,
|
|
sender_user_id=current_user.id,
|
|
body=payload.body,
|
|
)
|
|
db.add(msg)
|
|
await db.flush()
|
|
|
|
await track_event(
|
|
db,
|
|
event_name="message_sent",
|
|
request=request,
|
|
user_id=current_user.id,
|
|
is_authenticated=True,
|
|
source="terminal",
|
|
domain=listing.domain,
|
|
listing_id=listing.id,
|
|
inquiry_id=inquiry.id,
|
|
metadata={"role": "buyer"},
|
|
)
|
|
|
|
await track_event(
|
|
db,
|
|
event_name="message_sent",
|
|
request=request,
|
|
user_id=current_user.id,
|
|
is_authenticated=True,
|
|
source="terminal",
|
|
domain=listing.domain,
|
|
listing_id=listing.id,
|
|
inquiry_id=inquiry.id,
|
|
metadata={"role": "seller"},
|
|
)
|
|
|
|
# Email buyer (if configured)
|
|
try:
|
|
from app.services.email_service import email_service
|
|
if inquiry.buyer_user_id:
|
|
buyer = (
|
|
await db.execute(select(User).where(User.id == inquiry.buyer_user_id))
|
|
).scalar_one_or_none()
|
|
else:
|
|
buyer = None
|
|
if buyer and buyer.email and email_service.is_configured():
|
|
thread_url = f"https://pounce.ch/terminal/inbox?inquiry={inquiry.id}"
|
|
await email_service.send_listing_message(
|
|
to_email=buyer.email,
|
|
domain=listing.domain,
|
|
sender_name=current_user.name or current_user.email,
|
|
message=payload.body,
|
|
thread_url=thread_url,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send listing message notification: {e}")
|
|
|
|
await db.commit()
|
|
await db.refresh(msg)
|
|
return InquiryMessageResponse.model_validate(msg)
|
|
|
|
|
|
@router.get("/inquiries/my")
|
|
async def get_my_inquiries_as_buyer(
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Buyer: list inquiries created from this account."""
|
|
result = await db.execute(
|
|
select(ListingInquiry, DomainListing)
|
|
.join(DomainListing, DomainListing.id == ListingInquiry.listing_id)
|
|
.where(ListingInquiry.buyer_user_id == current_user.id)
|
|
.order_by(ListingInquiry.created_at.desc())
|
|
)
|
|
rows = result.all()
|
|
return [
|
|
{
|
|
"id": inq.id,
|
|
"listing_id": listing.id,
|
|
"domain": listing.domain,
|
|
"slug": listing.slug,
|
|
"status": inq.status,
|
|
"created_at": inq.created_at.isoformat(),
|
|
"closed_at": inq.closed_at.isoformat() if getattr(inq, "closed_at", None) else None,
|
|
"closed_reason": getattr(inq, "closed_reason", None),
|
|
}
|
|
for inq, listing in rows
|
|
]
|
|
|
|
|
|
@router.get("/inquiries/{inquiry_id}/messages", response_model=List[InquiryMessageResponse])
|
|
async def get_inquiry_messages_for_buyer(
|
|
inquiry_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Buyer: fetch thread messages for one inquiry."""
|
|
inquiry = (
|
|
await db.execute(select(ListingInquiry).where(ListingInquiry.id == inquiry_id))
|
|
).scalar_one_or_none()
|
|
if not inquiry or inquiry.buyer_user_id != current_user.id:
|
|
raise HTTPException(status_code=404, detail="Inquiry not found")
|
|
|
|
msgs = (
|
|
await db.execute(
|
|
select(ListingInquiryMessage)
|
|
.where(ListingInquiryMessage.inquiry_id == inquiry_id)
|
|
.order_by(ListingInquiryMessage.created_at.asc())
|
|
)
|
|
).scalars().all()
|
|
return [InquiryMessageResponse.model_validate(m) for m in msgs]
|
|
|
|
|
|
@router.post("/inquiries/{inquiry_id}/messages", response_model=InquiryMessageResponse)
|
|
async def post_inquiry_message_as_buyer(
|
|
inquiry_id: int,
|
|
payload: InquiryMessageCreate,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Buyer: post a message into an inquiry thread."""
|
|
inquiry = (
|
|
await db.execute(select(ListingInquiry).where(ListingInquiry.id == inquiry_id))
|
|
).scalar_one_or_none()
|
|
if not inquiry or inquiry.buyer_user_id != current_user.id:
|
|
raise HTTPException(status_code=404, detail="Inquiry not found")
|
|
|
|
if inquiry.status in ["closed", "spam"]:
|
|
raise HTTPException(status_code=400, detail="Inquiry is closed")
|
|
|
|
# Content safety (phishing keywords)
|
|
if not _check_content_safety(payload.body):
|
|
raise HTTPException(status_code=400, detail="Message contains blocked content. Please revise.")
|
|
|
|
# Simple rate limit: max 20 messages per hour per inquiry
|
|
hour_start = datetime.utcnow() - timedelta(hours=1)
|
|
msg_count = (
|
|
await db.execute(
|
|
select(func.count(ListingInquiryMessage.id)).where(
|
|
and_(
|
|
ListingInquiryMessage.inquiry_id == inquiry.id,
|
|
ListingInquiryMessage.sender_user_id == current_user.id,
|
|
ListingInquiryMessage.created_at >= hour_start,
|
|
)
|
|
)
|
|
)
|
|
).scalar() or 0
|
|
if msg_count >= 20:
|
|
raise HTTPException(status_code=429, detail="Too many messages. Please slow down.")
|
|
|
|
listing = (
|
|
await db.execute(select(DomainListing).where(DomainListing.id == inquiry.listing_id))
|
|
).scalar_one_or_none()
|
|
if not listing:
|
|
raise HTTPException(status_code=404, detail="Listing not found")
|
|
|
|
msg = ListingInquiryMessage(
|
|
inquiry_id=inquiry.id,
|
|
listing_id=listing.id,
|
|
sender_user_id=current_user.id,
|
|
body=payload.body,
|
|
)
|
|
db.add(msg)
|
|
await db.flush()
|
|
|
|
# Email seller (if configured)
|
|
try:
|
|
from app.services.email_service import email_service
|
|
seller = (
|
|
await db.execute(select(User).where(User.id == listing.user_id))
|
|
).scalar_one_or_none()
|
|
if seller and seller.email and email_service.is_configured():
|
|
thread_url = f"https://pounce.ch/terminal/listing"
|
|
await email_service.send_listing_message(
|
|
to_email=seller.email,
|
|
domain=listing.domain,
|
|
sender_name=current_user.name or current_user.email,
|
|
message=payload.body,
|
|
thread_url=thread_url,
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send listing message notification: {e}")
|
|
|
|
await db.commit()
|
|
await db.refresh(msg)
|
|
return InquiryMessageResponse.model_validate(msg)
|
|
|
|
|
|
@router.put("/{id}", response_model=ListingResponse)
|
|
async def update_listing(
|
|
id: int,
|
|
data: ListingUpdate,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Update a listing."""
|
|
result = await db.execute(
|
|
select(DomainListing).where(
|
|
and_(
|
|
DomainListing.id == id,
|
|
DomainListing.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
listing = result.scalar_one_or_none()
|
|
|
|
if not listing:
|
|
raise HTTPException(status_code=404, detail="Listing not found")
|
|
|
|
# Update fields
|
|
if data.title is not None:
|
|
listing.title = data.title
|
|
if data.description is not None:
|
|
listing.description = data.description
|
|
if data.asking_price is not None:
|
|
listing.asking_price = data.asking_price
|
|
if data.min_offer is not None:
|
|
listing.min_offer = data.min_offer
|
|
if data.price_type is not None:
|
|
listing.price_type = data.price_type
|
|
if data.show_valuation is not None:
|
|
listing.show_valuation = data.show_valuation
|
|
if data.allow_offers is not None:
|
|
listing.allow_offers = data.allow_offers
|
|
|
|
# Status change
|
|
if data.status is not None:
|
|
if data.status == "active" and listing.status == "draft":
|
|
# Publish listing
|
|
if not listing.is_verified:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Cannot publish without DNS verification"
|
|
)
|
|
listing.status = ListingStatus.ACTIVE.value
|
|
listing.published_at = datetime.utcnow()
|
|
elif data.status in ["draft", "expired"]:
|
|
listing.status = data.status
|
|
elif data.status == "sold":
|
|
if listing.status != ListingStatus.ACTIVE.value:
|
|
raise HTTPException(status_code=400, detail="Only active listings can be marked as sold.")
|
|
listing.status = ListingStatus.SOLD.value
|
|
listing.sold_at = datetime.utcnow()
|
|
listing.sold_reason = (data.sold_reason or "").strip() or listing.sold_reason
|
|
listing.sold_price = data.sold_price if data.sold_price is not None else listing.sold_price
|
|
listing.sold_currency = (data.sold_currency or listing.currency or "USD").upper()
|
|
|
|
# Close all open inquiries on this listing (deal is done).
|
|
inqs = (
|
|
await db.execute(
|
|
select(ListingInquiry).where(ListingInquiry.listing_id == listing.id)
|
|
)
|
|
).scalars().all()
|
|
for inq in inqs:
|
|
if inq.status in ["closed", "spam"]:
|
|
continue
|
|
old = inq.status
|
|
inq.status = "closed"
|
|
inq.closed_at = datetime.utcnow()
|
|
inq.closed_reason = inq.closed_reason or "sold"
|
|
db.add(
|
|
ListingInquiryEvent(
|
|
inquiry_id=inq.id,
|
|
listing_id=listing.id,
|
|
actor_user_id=current_user.id,
|
|
old_status=old,
|
|
new_status="closed",
|
|
reason="sold",
|
|
ip_address=request.client.host if request.client else None,
|
|
user_agent=request.headers.get("user-agent", "")[:500],
|
|
)
|
|
)
|
|
|
|
await track_event(
|
|
db,
|
|
event_name="listing_marked_sold",
|
|
request=request,
|
|
user_id=current_user.id,
|
|
is_authenticated=True,
|
|
source="terminal",
|
|
domain=listing.domain,
|
|
listing_id=listing.id,
|
|
metadata={
|
|
"sold_reason": listing.sold_reason,
|
|
"sold_price": float(listing.sold_price) if listing.sold_price is not None else None,
|
|
"sold_currency": listing.sold_currency,
|
|
},
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(listing)
|
|
|
|
return ListingResponse(
|
|
id=listing.id,
|
|
domain=listing.domain,
|
|
slug=listing.slug,
|
|
title=listing.title,
|
|
description=listing.description,
|
|
asking_price=listing.asking_price,
|
|
min_offer=listing.min_offer,
|
|
currency=listing.currency,
|
|
price_type=listing.price_type,
|
|
pounce_score=listing.pounce_score,
|
|
estimated_value=listing.estimated_value,
|
|
verification_status=listing.verification_status,
|
|
is_verified=listing.is_verified,
|
|
status=listing.status,
|
|
show_valuation=listing.show_valuation,
|
|
allow_offers=listing.allow_offers,
|
|
view_count=listing.view_count,
|
|
inquiry_count=listing.inquiry_count,
|
|
public_url=listing.public_url,
|
|
created_at=listing.created_at,
|
|
published_at=listing.published_at,
|
|
sold_at=getattr(listing, "sold_at", None),
|
|
sold_reason=getattr(listing, "sold_reason", None),
|
|
sold_price=getattr(listing, "sold_price", None),
|
|
sold_currency=getattr(listing, "sold_currency", None),
|
|
seller_verified=current_user.is_verified,
|
|
seller_member_since=current_user.created_at,
|
|
)
|
|
|
|
|
|
@router.delete("/{id}")
|
|
async def delete_listing(
|
|
id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete a listing."""
|
|
result = await db.execute(
|
|
select(DomainListing).where(
|
|
and_(
|
|
DomainListing.id == id,
|
|
DomainListing.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
listing = result.scalar_one_or_none()
|
|
|
|
if not listing:
|
|
raise HTTPException(status_code=404, detail="Listing not found")
|
|
|
|
await db.delete(listing)
|
|
await db.commit()
|
|
|
|
return {"success": True, "message": "Listing deleted"}
|
|
|
|
|
|
@router.post("/{id}/verify-dns", response_model=VerificationResponse)
|
|
async def start_dns_verification(
|
|
id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Start DNS verification for a listing."""
|
|
result = await db.execute(
|
|
select(DomainListing).where(
|
|
and_(
|
|
DomainListing.id == id,
|
|
DomainListing.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
listing = result.scalar_one_or_none()
|
|
|
|
if not listing:
|
|
raise HTTPException(status_code=404, detail="Listing not found")
|
|
|
|
# Generate new code if needed
|
|
if not listing.verification_code:
|
|
listing.verification_code = _generate_verification_code()
|
|
|
|
listing.verification_status = VerificationStatus.PENDING.value
|
|
await db.commit()
|
|
|
|
# Extract domain root for DNS
|
|
domain_parts = listing.domain.split('.')
|
|
if len(domain_parts) > 2:
|
|
dns_name = f"_pounce.{'.'.join(domain_parts[-2:])}"
|
|
else:
|
|
dns_name = f"_pounce.{listing.domain}"
|
|
|
|
return VerificationResponse(
|
|
verification_code=listing.verification_code,
|
|
dns_record_type="TXT",
|
|
dns_record_name=dns_name,
|
|
dns_record_value=listing.verification_code,
|
|
instructions=f"""
|
|
To verify ownership of {listing.domain}:
|
|
|
|
1. Go to your domain registrar's DNS settings
|
|
2. Add a new TXT record:
|
|
- Name/Host: _pounce (or _pounce.{listing.domain})
|
|
- Value: {listing.verification_code}
|
|
- TTL: 300 (or lowest available)
|
|
3. Wait 1-5 minutes for DNS propagation
|
|
4. Click "Check Verification" to complete
|
|
|
|
This proves you control the domain's DNS, confirming ownership.
|
|
""".strip(),
|
|
status=listing.verification_status,
|
|
)
|
|
|
|
|
|
@router.get("/{id}/verify-dns/check")
|
|
async def check_dns_verification(
|
|
id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Check DNS verification status."""
|
|
result = await db.execute(
|
|
select(DomainListing).where(
|
|
and_(
|
|
DomainListing.id == id,
|
|
DomainListing.user_id == current_user.id,
|
|
)
|
|
)
|
|
)
|
|
listing = result.scalar_one_or_none()
|
|
|
|
if not listing:
|
|
raise HTTPException(status_code=404, detail="Listing not found")
|
|
|
|
if not listing.verification_code:
|
|
raise HTTPException(status_code=400, detail="Start verification first")
|
|
|
|
# Check DNS TXT record
|
|
import dns.resolver
|
|
|
|
try:
|
|
domain_parts = listing.domain.split('.')
|
|
if len(domain_parts) > 2:
|
|
dns_name = f"_pounce.{'.'.join(domain_parts[-2:])}"
|
|
else:
|
|
dns_name = f"_pounce.{listing.domain}"
|
|
|
|
answers = dns.resolver.resolve(dns_name, 'TXT')
|
|
|
|
for rdata in answers:
|
|
txt_value = str(rdata).strip('"')
|
|
if txt_value == listing.verification_code:
|
|
# Verified!
|
|
listing.verification_status = VerificationStatus.VERIFIED.value
|
|
listing.verified_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
return {
|
|
"verified": True,
|
|
"status": "verified",
|
|
"message": "DNS verification successful! You can now publish your listing.",
|
|
}
|
|
|
|
# Code not found
|
|
return {
|
|
"verified": False,
|
|
"status": "pending",
|
|
"message": "TXT record found but value doesn't match. Please check the value.",
|
|
}
|
|
|
|
except dns.resolver.NXDOMAIN:
|
|
return {
|
|
"verified": False,
|
|
"status": "pending",
|
|
"message": "DNS record not found. Please add the TXT record and wait for propagation.",
|
|
}
|
|
except dns.resolver.NoAnswer:
|
|
return {
|
|
"verified": False,
|
|
"status": "pending",
|
|
"message": "No TXT record found. Please add the record.",
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"DNS check failed for {listing.domain}: {e}")
|
|
return {
|
|
"verified": False,
|
|
"status": "error",
|
|
"message": "DNS check failed. Please try again in a few minutes.",
|
|
}
|
|
|