From 18d50e96f46be5219a50caafefb6a12a5eec7101 Mon Sep 17 00:00:00 2001 From: "yves.gugger" Date: Wed, 10 Dec 2025 11:44:56 +0100 Subject: [PATCH] feat: Add For Sale Marketplace + Sniper Alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BACKEND - New Models: - DomainListing: For sale landing pages with DNS verification - ListingInquiry: Contact form submissions from buyers - ListingView: Analytics tracking - SniperAlert: Hyper-personalized auction filters - SniperAlertMatch: Matched auctions for alerts BACKEND - New APIs: - /listings: Browse, create, manage domain listings - /listings/{slug}/inquire: Buyer contact form - /listings/{id}/verify-dns: DNS ownership verification - /sniper-alerts: Create, manage, test alert filters FRONTEND - New Pages: - /buy: Public marketplace browse page - /buy/[slug]: Individual listing page with contact form - /command/listings: Manage your listings - /command/alerts: Sniper alerts dashboard FRONTEND - Updated: - Sidebar: Added For Sale + Sniper Alerts nav items - Landing page: New features teaser section DOCS: - DATABASE_MIGRATIONS.md: Complete SQL for new tables From analysis_3.md: - Strategie 2: Micro-Marktplatz (For Sale Pages) - Strategie 4: Alerts nach Maß (Sniper Alerts) - Säule 2: DNS Ownership Verification --- DATABASE_MIGRATIONS.md | 246 +++++++ backend/app/api/__init__.py | 8 + backend/app/api/listings.py | 814 +++++++++++++++++++++ backend/app/api/sniper_alerts.py | 457 ++++++++++++ backend/app/models/__init__.py | 9 + backend/app/models/listing.py | 203 +++++ backend/app/models/sniper_alert.py | 183 +++++ backend/app/models/user.py | 8 + frontend/src/app/buy/[slug]/page.tsx | 460 ++++++++++++ frontend/src/app/buy/page.tsx | 304 ++++++++ frontend/src/app/command/alerts/page.tsx | 598 +++++++++++++++ frontend/src/app/command/listings/page.tsx | 561 ++++++++++++++ frontend/src/app/page.tsx | 96 ++- frontend/src/components/Sidebar.tsx | 14 + 14 files changed, 3960 insertions(+), 1 deletion(-) create mode 100644 DATABASE_MIGRATIONS.md create mode 100644 backend/app/api/listings.py create mode 100644 backend/app/api/sniper_alerts.py create mode 100644 backend/app/models/listing.py create mode 100644 backend/app/models/sniper_alert.py create mode 100644 frontend/src/app/buy/[slug]/page.tsx create mode 100644 frontend/src/app/buy/page.tsx create mode 100644 frontend/src/app/command/alerts/page.tsx create mode 100644 frontend/src/app/command/listings/page.tsx diff --git a/DATABASE_MIGRATIONS.md b/DATABASE_MIGRATIONS.md new file mode 100644 index 0000000..1971997 --- /dev/null +++ b/DATABASE_MIGRATIONS.md @@ -0,0 +1,246 @@ +# Database Migrations Guide + +## Overview + +This document lists all database tables that need to be created when deploying Pounce to a new server. + +## Required Tables + +### Core Tables (Already Implemented) + +| Table | Model | Description | +|-------|-------|-------------| +| `users` | User | User accounts and authentication | +| `subscriptions` | Subscription | User subscription plans (Scout, Trader, Tycoon) | +| `domains` | Domain | Tracked domains in watchlists | +| `domain_checks` | DomainCheck | Domain availability check history | +| `tld_prices` | TLDPrice | TLD price history | +| `tld_info` | TLDInfo | TLD metadata | +| `portfolio_domains` | PortfolioDomain | User-owned domains | +| `domain_valuations` | DomainValuation | Domain valuation history | +| `domain_auctions` | DomainAuction | Scraped auction listings | +| `auction_scrape_logs` | AuctionScrapeLog | Scraping job logs | +| `newsletter_subscribers` | NewsletterSubscriber | Email newsletter list | +| `price_alerts` | PriceAlert | TLD price change alerts | +| `admin_activity_logs` | AdminActivityLog | Admin action audit log | +| `blog_posts` | BlogPost | Blog content | + +--- + +### NEW Tables (To Be Created) + +These tables were added for the **"For Sale" Marketplace** and **Sniper Alerts** features: + +#### 1. Domain Listings (For Sale Marketplace) + +```sql +-- Main listing table +CREATE TABLE domain_listings ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + domain VARCHAR(255) NOT NULL UNIQUE, + slug VARCHAR(300) NOT NULL UNIQUE, + title VARCHAR(200), + description TEXT, + asking_price FLOAT, + min_offer FLOAT, + currency VARCHAR(3) DEFAULT 'USD', + price_type VARCHAR(20) DEFAULT 'fixed', + pounce_score INTEGER, + estimated_value FLOAT, + verification_status VARCHAR(20) DEFAULT 'not_started', + verification_code VARCHAR(64), + verified_at TIMESTAMP, + status VARCHAR(30) DEFAULT 'draft', + show_valuation BOOLEAN DEFAULT TRUE, + allow_offers BOOLEAN DEFAULT TRUE, + featured BOOLEAN DEFAULT FALSE, + view_count INTEGER DEFAULT 0, + inquiry_count INTEGER DEFAULT 0, + expires_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + published_at TIMESTAMP +); + +CREATE INDEX idx_listings_user_id ON domain_listings(user_id); +CREATE INDEX idx_listings_domain ON domain_listings(domain); +CREATE INDEX idx_listings_slug ON domain_listings(slug); +CREATE INDEX idx_listings_status ON domain_listings(status); +``` + +```sql +-- Contact inquiries from potential buyers +CREATE TABLE listing_inquiries ( + id SERIAL PRIMARY KEY, + listing_id INTEGER NOT NULL REFERENCES domain_listings(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + phone VARCHAR(50), + company VARCHAR(200), + message TEXT NOT NULL, + offer_amount FLOAT, + status VARCHAR(20) DEFAULT 'new', + ip_address VARCHAR(45), + user_agent VARCHAR(500), + created_at TIMESTAMP DEFAULT NOW(), + read_at TIMESTAMP, + replied_at TIMESTAMP +); + +CREATE INDEX idx_inquiries_listing_id ON listing_inquiries(listing_id); +``` + +```sql +-- Analytics: page views +CREATE TABLE listing_views ( + id SERIAL PRIMARY KEY, + listing_id INTEGER NOT NULL REFERENCES domain_listings(id) ON DELETE CASCADE, + ip_address VARCHAR(45), + user_agent VARCHAR(500), + referrer VARCHAR(500), + user_id INTEGER REFERENCES users(id), + viewed_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_views_listing_id ON listing_views(listing_id); +``` + +#### 2. Sniper Alerts + +```sql +-- Saved filter configurations for personalized alerts +CREATE TABLE sniper_alerts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + name VARCHAR(100) NOT NULL, + description VARCHAR(500), + filter_criteria JSONB NOT NULL DEFAULT '{}', + tlds VARCHAR(500), + keywords VARCHAR(500), + exclude_keywords VARCHAR(500), + max_length INTEGER, + min_length INTEGER, + max_price FLOAT, + min_price FLOAT, + max_bids INTEGER, + ending_within_hours INTEGER, + platforms VARCHAR(200), + no_numbers BOOLEAN DEFAULT FALSE, + no_hyphens BOOLEAN DEFAULT FALSE, + exclude_chars VARCHAR(50), + notify_email BOOLEAN DEFAULT TRUE, + notify_sms BOOLEAN DEFAULT FALSE, + notify_push BOOLEAN DEFAULT FALSE, + max_notifications_per_day INTEGER DEFAULT 10, + cooldown_minutes INTEGER DEFAULT 30, + is_active BOOLEAN DEFAULT TRUE, + matches_count INTEGER DEFAULT 0, + notifications_sent INTEGER DEFAULT 0, + last_matched_at TIMESTAMP, + last_notified_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_alerts_user_id ON sniper_alerts(user_id); +CREATE INDEX idx_alerts_active ON sniper_alerts(is_active); +``` + +```sql +-- Matched auctions for each alert +CREATE TABLE sniper_alert_matches ( + id SERIAL PRIMARY KEY, + alert_id INTEGER NOT NULL REFERENCES sniper_alerts(id) ON DELETE CASCADE, + domain VARCHAR(255) NOT NULL, + platform VARCHAR(50) NOT NULL, + current_bid FLOAT NOT NULL, + end_time TIMESTAMP NOT NULL, + auction_url VARCHAR(500), + notified BOOLEAN DEFAULT FALSE, + clicked BOOLEAN DEFAULT FALSE, + matched_at TIMESTAMP DEFAULT NOW(), + notified_at TIMESTAMP +); + +CREATE INDEX idx_matches_alert_id ON sniper_alert_matches(alert_id); +``` + +--- + +## Migration Commands + +### Using Alembic (Recommended) + +```bash +cd backend + +# Generate migration +alembic revision --autogenerate -m "Add marketplace and sniper alert tables" + +# Apply migration +alembic upgrade head +``` + +### Manual SQL (Alternative) + +Run the SQL statements above in order on your PostgreSQL database. + +--- + +## Verification + +After running migrations, verify tables exist: + +```sql +SELECT table_name +FROM information_schema.tables +WHERE table_schema = 'public' +AND table_name IN ( + 'domain_listings', + 'listing_inquiries', + 'listing_views', + 'sniper_alerts', + 'sniper_alert_matches' +); +``` + +--- + +## Feature References + +These tables implement features from: + +- **analysis_3.md** - "Micro-Marktplatz" (For Sale Landing Pages) +- **analysis_3.md** - "Sniper Alerts" (Strategie 4: Alerts nach Maß) +- **concept.md** - "For Sale Pages" marketplace feature + +--- + +## API Endpoints + +### Listings (For Sale) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/listings` | Browse public listings | +| GET | `/api/v1/listings/{slug}` | View listing details | +| POST | `/api/v1/listings/{slug}/inquire` | Contact seller | +| POST | `/api/v1/listings` | Create listing (auth) | +| GET | `/api/v1/listings/my` | Get my listings (auth) | +| PUT | `/api/v1/listings/{id}` | Update listing (auth) | +| DELETE | `/api/v1/listings/{id}` | Delete listing (auth) | +| POST | `/api/v1/listings/{id}/verify-dns` | Start verification | +| GET | `/api/v1/listings/{id}/verify-dns/check` | Check verification | + +### Sniper Alerts + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/sniper-alerts` | Get my alerts | +| POST | `/api/v1/sniper-alerts` | Create alert | +| PUT | `/api/v1/sniper-alerts/{id}` | Update alert | +| DELETE | `/api/v1/sniper-alerts/{id}` | Delete alert | +| GET | `/api/v1/sniper-alerts/{id}/matches` | Get matched auctions | +| POST | `/api/v1/sniper-alerts/{id}/test` | Test alert criteria | + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index e0a1520..2e75ba5 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -14,6 +14,8 @@ from app.api.webhooks import router as webhooks_router from app.api.contact import router as contact_router from app.api.price_alerts import router as price_alerts_router from app.api.blog import router as blog_router +from app.api.listings import router as listings_router +from app.api.sniper_alerts import router as sniper_alerts_router api_router = APIRouter() @@ -28,6 +30,12 @@ api_router.include_router(price_alerts_router, prefix="/price-alerts", tags=["Pr api_router.include_router(portfolio_router, prefix="/portfolio", tags=["Portfolio"]) api_router.include_router(auctions_router, prefix="/auctions", tags=["Smart Pounce - Auctions"]) +# Marketplace (For Sale) - from analysis_3.md +api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"]) + +# Sniper Alerts - from analysis_3.md +api_router.include_router(sniper_alerts_router, prefix="/sniper-alerts", tags=["Sniper Alerts"]) + # Support & Communication api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Newsletter"]) diff --git a/backend/app/api/listings.py b/backend/app/api/listings.py new file mode 100644 index 0000000..4f9543e --- /dev/null +++ b/backend/app/api/listings.py @@ -0,0 +1,814 @@ +""" +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 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, ListingView, ListingStatus, VerificationStatus +from app.services.valuation import valuation_service + +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 + + +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] + + # 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] + + 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] + + 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), + 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).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: + 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=listing.pounce_score if listing.show_valuation else None, + 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, + )) + + return responses + + +@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).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 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=listing.pounce_score if listing.show_valuation else None, + 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, + ) + + +@router.post("/{slug}/inquire") +async def submit_inquiry( + slug: str, + inquiry: InquiryCreate, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Submit an inquiry for a listing (public).""" + # 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") + + # 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 email 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.email == inquiry.email.lower(), + 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, + 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) + + # Increment inquiry count + listing.inquiry_count += 1 + + await db.commit() + + # TODO: Send email notification to seller + + return { + "success": True, + "message": "Your inquiry has been sent to the seller.", + } + + +# ============== Authenticated Endpoints ============== + +@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.""" + # Check if domain is already listed + existing = await db.execute( + select(DomainListing).where(DomainListing.domain == data.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 + tier = current_user.subscription.tier if current_user.subscription else "scout" + limits = {"scout": 2, "trader": 10, "tycoon": 50} + max_listings = limits.get(tier, 2) + + 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(data.domain) + + # 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(data.domain, db, save_result=False) + pounce_score = min(100, int(valuation.get("score", 50))) + estimated_value = valuation.get("estimated_value", 0) + except Exception: + pounce_score = 50 + estimated_value = None + + # Create listing + listing = DomainListing( + user_id=current_user.id, + domain=data.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=_generate_verification_code(), + 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("/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, + seller_verified=current_user.is_verified, + seller_member_since=current_user.created_at, + ) + for listing in listings + ] + + +@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, + ) + for inq in inquiries + ] + + +@router.put("/{id}", response_model=ListingResponse) +async def update_listing( + id: int, + data: ListingUpdate, + 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", "sold", "expired"]: + listing.status = data.status + + 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.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.", + } + diff --git a/backend/app/api/sniper_alerts.py b/backend/app/api/sniper_alerts.py new file mode 100644 index 0000000..80c3ab7 --- /dev/null +++ b/backend/app/api/sniper_alerts.py @@ -0,0 +1,457 @@ +""" +Sniper Alerts API - Hyper-personalized auction notifications + +This implements "Strategie 4: Alerts nach Maß" from analysis_3.md: +"Der User kann extrem spezifische Filter speichern: +- Informiere mich NUR, wenn eine 4-Letter .com Domain droppt, die kein 'q' oder 'x' enthält." + +Endpoints: +- GET /sniper-alerts - Get user's alerts +- POST /sniper-alerts - Create new alert +- PUT /sniper-alerts/{id} - Update alert +- DELETE /sniper-alerts/{id} - Delete alert +- GET /sniper-alerts/{id}/matches - Get matched auctions +- POST /sniper-alerts/{id}/test - Test alert against current auctions +""" +import logging +from datetime import datetime +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.api.deps import get_current_user +from app.models.user import User +from app.models.sniper_alert import SniperAlert, SniperAlertMatch +from app.models.auction import DomainAuction + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ============== Schemas ============== + +class SniperAlertCreate(BaseModel): + """Create a new sniper alert.""" + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = Field(None, max_length=500) + + # Filter criteria + tlds: Optional[str] = Field(None, description="Comma-separated TLDs: com,io,ai") + keywords: Optional[str] = Field(None, description="Must contain (comma-separated)") + exclude_keywords: Optional[str] = Field(None, description="Must not contain") + max_length: Optional[int] = Field(None, ge=1, le=63) + min_length: Optional[int] = Field(None, ge=1, le=63) + max_price: Optional[float] = Field(None, ge=0) + min_price: Optional[float] = Field(None, ge=0) + max_bids: Optional[int] = Field(None, ge=0, description="Max bids (low competition)") + ending_within_hours: Optional[int] = Field(None, ge=1, le=168) + platforms: Optional[str] = Field(None, description="Comma-separated platforms") + + # Advanced + no_numbers: bool = False + no_hyphens: bool = False + exclude_chars: Optional[str] = Field(None, description="Chars to exclude: q,x,z") + + # Notifications + notify_email: bool = True + notify_sms: bool = False + + +class SniperAlertUpdate(BaseModel): + """Update a sniper alert.""" + name: Optional[str] = Field(None, max_length=100) + description: Optional[str] = Field(None, max_length=500) + tlds: Optional[str] = None + keywords: Optional[str] = None + exclude_keywords: Optional[str] = None + max_length: Optional[int] = Field(None, ge=1, le=63) + min_length: Optional[int] = Field(None, ge=1, le=63) + max_price: Optional[float] = Field(None, ge=0) + min_price: Optional[float] = Field(None, ge=0) + max_bids: Optional[int] = Field(None, ge=0) + ending_within_hours: Optional[int] = Field(None, ge=1, le=168) + platforms: Optional[str] = None + no_numbers: Optional[bool] = None + no_hyphens: Optional[bool] = None + exclude_chars: Optional[str] = None + notify_email: Optional[bool] = None + notify_sms: Optional[bool] = None + is_active: Optional[bool] = None + + +class SniperAlertResponse(BaseModel): + """Sniper alert response.""" + id: int + name: str + description: Optional[str] + tlds: Optional[str] + keywords: Optional[str] + exclude_keywords: Optional[str] + max_length: Optional[int] + min_length: Optional[int] + max_price: Optional[float] + min_price: Optional[float] + max_bids: Optional[int] + ending_within_hours: Optional[int] + platforms: Optional[str] + no_numbers: bool + no_hyphens: bool + exclude_chars: Optional[str] + notify_email: bool + notify_sms: bool + is_active: bool + matches_count: int + notifications_sent: int + last_matched_at: Optional[datetime] + created_at: datetime + + class Config: + from_attributes = True + + +class MatchResponse(BaseModel): + """Alert match response.""" + id: int + domain: str + platform: str + current_bid: float + end_time: datetime + auction_url: Optional[str] + matched_at: datetime + notified: bool + + class Config: + from_attributes = True + + +# ============== Endpoints ============== + +@router.get("", response_model=List[SniperAlertResponse]) +async def get_sniper_alerts( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get user's sniper alerts.""" + result = await db.execute( + select(SniperAlert) + .where(SniperAlert.user_id == current_user.id) + .order_by(SniperAlert.created_at.desc()) + ) + alerts = list(result.scalars().all()) + + return [ + SniperAlertResponse( + id=alert.id, + name=alert.name, + description=alert.description, + tlds=alert.tlds, + keywords=alert.keywords, + exclude_keywords=alert.exclude_keywords, + max_length=alert.max_length, + min_length=alert.min_length, + max_price=alert.max_price, + min_price=alert.min_price, + max_bids=alert.max_bids, + ending_within_hours=alert.ending_within_hours, + platforms=alert.platforms, + no_numbers=alert.no_numbers, + no_hyphens=alert.no_hyphens, + exclude_chars=alert.exclude_chars, + notify_email=alert.notify_email, + notify_sms=alert.notify_sms, + is_active=alert.is_active, + matches_count=alert.matches_count, + notifications_sent=alert.notifications_sent, + last_matched_at=alert.last_matched_at, + created_at=alert.created_at, + ) + for alert in alerts + ] + + +@router.post("", response_model=SniperAlertResponse) +async def create_sniper_alert( + data: SniperAlertCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Create a new sniper alert.""" + # Check alert limit based on subscription + user_alerts = await db.execute( + select(func.count(SniperAlert.id)).where( + SniperAlert.user_id == current_user.id + ) + ) + alert_count = user_alerts.scalar() or 0 + + tier = current_user.subscription.tier if current_user.subscription else "scout" + limits = {"scout": 2, "trader": 10, "tycoon": 50} + max_alerts = limits.get(tier, 2) + + if alert_count >= max_alerts: + raise HTTPException( + status_code=403, + detail=f"Alert limit reached ({max_alerts}). Upgrade for more." + ) + + # SMS notifications are Tycoon only + if data.notify_sms and tier != "tycoon": + raise HTTPException( + status_code=403, + detail="SMS notifications are a Tycoon feature" + ) + + # Build filter criteria JSON + filter_criteria = { + "tlds": data.tlds.split(',') if data.tlds else None, + "keywords": data.keywords.split(',') if data.keywords else None, + "exclude_keywords": data.exclude_keywords.split(',') if data.exclude_keywords else None, + "max_length": data.max_length, + "min_length": data.min_length, + "max_price": data.max_price, + "min_price": data.min_price, + "max_bids": data.max_bids, + "ending_within_hours": data.ending_within_hours, + "platforms": data.platforms.split(',') if data.platforms else None, + "no_numbers": data.no_numbers, + "no_hyphens": data.no_hyphens, + "exclude_chars": data.exclude_chars.split(',') if data.exclude_chars else None, + } + + alert = SniperAlert( + user_id=current_user.id, + name=data.name, + description=data.description, + filter_criteria=filter_criteria, + tlds=data.tlds, + keywords=data.keywords, + exclude_keywords=data.exclude_keywords, + max_length=data.max_length, + min_length=data.min_length, + max_price=data.max_price, + min_price=data.min_price, + max_bids=data.max_bids, + ending_within_hours=data.ending_within_hours, + platforms=data.platforms, + no_numbers=data.no_numbers, + no_hyphens=data.no_hyphens, + exclude_chars=data.exclude_chars, + notify_email=data.notify_email, + notify_sms=data.notify_sms, + ) + + db.add(alert) + await db.commit() + await db.refresh(alert) + + return SniperAlertResponse( + id=alert.id, + name=alert.name, + description=alert.description, + tlds=alert.tlds, + keywords=alert.keywords, + exclude_keywords=alert.exclude_keywords, + max_length=alert.max_length, + min_length=alert.min_length, + max_price=alert.max_price, + min_price=alert.min_price, + max_bids=alert.max_bids, + ending_within_hours=alert.ending_within_hours, + platforms=alert.platforms, + no_numbers=alert.no_numbers, + no_hyphens=alert.no_hyphens, + exclude_chars=alert.exclude_chars, + notify_email=alert.notify_email, + notify_sms=alert.notify_sms, + is_active=alert.is_active, + matches_count=alert.matches_count, + notifications_sent=alert.notifications_sent, + last_matched_at=alert.last_matched_at, + created_at=alert.created_at, + ) + + +@router.put("/{id}", response_model=SniperAlertResponse) +async def update_sniper_alert( + id: int, + data: SniperAlertUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Update a sniper alert.""" + result = await db.execute( + select(SniperAlert).where( + and_( + SniperAlert.id == id, + SniperAlert.user_id == current_user.id, + ) + ) + ) + alert = result.scalar_one_or_none() + + if not alert: + raise HTTPException(status_code=404, detail="Alert not found") + + # Update fields + update_fields = data.model_dump(exclude_unset=True) + for field, value in update_fields.items(): + if hasattr(alert, field): + setattr(alert, field, value) + + await db.commit() + await db.refresh(alert) + + return SniperAlertResponse( + id=alert.id, + name=alert.name, + description=alert.description, + tlds=alert.tlds, + keywords=alert.keywords, + exclude_keywords=alert.exclude_keywords, + max_length=alert.max_length, + min_length=alert.min_length, + max_price=alert.max_price, + min_price=alert.min_price, + max_bids=alert.max_bids, + ending_within_hours=alert.ending_within_hours, + platforms=alert.platforms, + no_numbers=alert.no_numbers, + no_hyphens=alert.no_hyphens, + exclude_chars=alert.exclude_chars, + notify_email=alert.notify_email, + notify_sms=alert.notify_sms, + is_active=alert.is_active, + matches_count=alert.matches_count, + notifications_sent=alert.notifications_sent, + last_matched_at=alert.last_matched_at, + created_at=alert.created_at, + ) + + +@router.delete("/{id}") +async def delete_sniper_alert( + id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Delete a sniper alert.""" + result = await db.execute( + select(SniperAlert).where( + and_( + SniperAlert.id == id, + SniperAlert.user_id == current_user.id, + ) + ) + ) + alert = result.scalar_one_or_none() + + if not alert: + raise HTTPException(status_code=404, detail="Alert not found") + + await db.delete(alert) + await db.commit() + + return {"success": True, "message": "Alert deleted"} + + +@router.get("/{id}/matches", response_model=List[MatchResponse]) +async def get_alert_matches( + id: int, + limit: int = 50, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get matched auctions for an alert.""" + # Verify ownership + result = await db.execute( + select(SniperAlert).where( + and_( + SniperAlert.id == id, + SniperAlert.user_id == current_user.id, + ) + ) + ) + alert = result.scalar_one_or_none() + + if not alert: + raise HTTPException(status_code=404, detail="Alert not found") + + matches_result = await db.execute( + select(SniperAlertMatch) + .where(SniperAlertMatch.alert_id == id) + .order_by(SniperAlertMatch.matched_at.desc()) + .limit(limit) + ) + matches = list(matches_result.scalars().all()) + + return [ + MatchResponse( + id=m.id, + domain=m.domain, + platform=m.platform, + current_bid=m.current_bid, + end_time=m.end_time, + auction_url=m.auction_url, + matched_at=m.matched_at, + notified=m.notified, + ) + for m in matches + ] + + +@router.post("/{id}/test") +async def test_sniper_alert( + id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Test alert against current auctions.""" + # Verify ownership + result = await db.execute( + select(SniperAlert).where( + and_( + SniperAlert.id == id, + SniperAlert.user_id == current_user.id, + ) + ) + ) + alert = result.scalar_one_or_none() + + if not alert: + raise HTTPException(status_code=404, detail="Alert not found") + + # Get active auctions + auctions_result = await db.execute( + select(DomainAuction) + .where(DomainAuction.is_active == True) + .limit(500) + ) + auctions = list(auctions_result.scalars().all()) + + matches = [] + for auction in auctions: + if alert.matches_domain( + auction.domain, + auction.tld, + auction.current_bid, + auction.num_bids + ): + matches.append({ + "domain": auction.domain, + "platform": auction.platform, + "current_bid": auction.current_bid, + "num_bids": auction.num_bids, + "end_time": auction.end_time.isoformat(), + }) + + return { + "alert_name": alert.name, + "auctions_checked": len(auctions), + "matches_found": len(matches), + "matches": matches[:20], # Limit to 20 for preview + "message": f"Found {len(matches)} matching auctions" if matches else "No matches found. Try adjusting your criteria.", + } + diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6af8c01..4007714 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,8 @@ from app.models.newsletter import NewsletterSubscriber from app.models.price_alert import PriceAlert from app.models.admin_log import AdminActivityLog from app.models.blog import BlogPost +from app.models.listing import DomainListing, ListingInquiry, ListingView +from app.models.sniper_alert import SniperAlert, SniperAlertMatch __all__ = [ "User", @@ -25,4 +27,11 @@ __all__ = [ "PriceAlert", "AdminActivityLog", "BlogPost", + # New: For Sale / Marketplace + "DomainListing", + "ListingInquiry", + "ListingView", + # New: Sniper Alerts + "SniperAlert", + "SniperAlertMatch", ] diff --git a/backend/app/models/listing.py b/backend/app/models/listing.py new file mode 100644 index 0000000..cd41772 --- /dev/null +++ b/backend/app/models/listing.py @@ -0,0 +1,203 @@ +""" +Domain Listing models for "Pounce For Sale" feature. + +This implements the "Micro-Marktplatz" strategy from analysis_3.md: +- Users can create professional landing pages for domains they want to sell +- Buyers can contact sellers through Pounce +- DNS verification ensures only real owners can list domains + +DATABASE TABLES TO CREATE: +1. domain_listings - Main listing table +2. listing_inquiries - Contact requests from potential buyers +3. listing_views - Track views for analytics + +Run migrations: alembic upgrade head +""" +from datetime import datetime +from typing import Optional, List +from sqlalchemy import String, DateTime, Float, Integer, Text, ForeignKey, Boolean, Enum as SQLEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship +import enum + +from app.database import Base + + +class ListingStatus(str, enum.Enum): + """Status of a domain listing.""" + DRAFT = "draft" # Not yet published + PENDING_VERIFICATION = "pending_verification" # Awaiting DNS verification + ACTIVE = "active" # Live and visible + SOLD = "sold" # Marked as sold + EXPIRED = "expired" # Listing expired + SUSPENDED = "suspended" # Suspended by admin + + +class VerificationStatus(str, enum.Enum): + """DNS verification status.""" + NOT_STARTED = "not_started" + PENDING = "pending" + VERIFIED = "verified" + FAILED = "failed" + + +class DomainListing(Base): + """ + Domain listing for the Pounce marketplace. + + Users can list their domains for sale with a professional landing page. + URL: pounce.ch/buy/{slug} + + Features: + - DNS verification for ownership proof + - Professional landing page with valuation + - Contact form for buyers + - Analytics (views, inquiries) + + From analysis_3.md: + "Ein User (Trader/Tycoon) kann für seine Domains mit einem Klick + eine schicke Verkaufsseite erstellen." + """ + + __tablename__ = "domain_listings" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False) + + # Domain info + domain: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(300), unique=True, nullable=False, index=True) + + # Listing details + title: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) # Custom headline + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # Pricing + asking_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + min_offer: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + currency: Mapped[str] = mapped_column(String(3), default="USD") + price_type: Mapped[str] = mapped_column(String(20), default="fixed") # fixed, negotiable, make_offer + + # Pounce valuation (calculated) + pounce_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100 + estimated_value: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + + # Verification (from analysis_3.md - Säule 2: Asset Verification) + verification_status: Mapped[str] = mapped_column( + String(20), + default=VerificationStatus.NOT_STARTED.value + ) + verification_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Status + status: Mapped[str] = mapped_column(String(30), default=ListingStatus.DRAFT.value, index=True) + + # Features + show_valuation: Mapped[bool] = mapped_column(Boolean, default=True) + allow_offers: Mapped[bool] = mapped_column(Boolean, default=True) + featured: Mapped[bool] = mapped_column(Boolean, default=False) # Premium placement + + # Analytics + view_count: Mapped[int] = mapped_column(Integer, default=0) + inquiry_count: Mapped[int] = mapped_column(Integer, default=0) + + # Expiry + expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + published_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Relationships + user: Mapped["User"] = relationship("User", back_populates="listings") + inquiries: Mapped[List["ListingInquiry"]] = relationship( + "ListingInquiry", back_populates="listing", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + @property + def is_verified(self) -> bool: + return self.verification_status == VerificationStatus.VERIFIED.value + + @property + def is_active(self) -> bool: + return self.status == ListingStatus.ACTIVE.value + + @property + def public_url(self) -> str: + return f"/buy/{self.slug}" + + +class ListingInquiry(Base): + """ + Contact request from a potential buyer. + + From analysis_3.md: + "Ein einfaches Kontaktformular, das die Anfrage direkt an den User leitet." + + Security (from analysis_3.md - Säule 3): + - Keyword blocking for phishing prevention + - Rate limiting per IP/user + """ + + __tablename__ = "listing_inquiries" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False) + + # Inquirer info + name: Mapped[str] = mapped_column(String(100), nullable=False) + email: Mapped[str] = mapped_column(String(255), nullable=False) + phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + company: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) + + # Message + message: Mapped[str] = mapped_column(Text, nullable=False) + offer_amount: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + + # Status + status: Mapped[str] = mapped_column(String(20), default="new") # new, read, replied, spam + + # Tracking + ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) + user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + read_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + replied_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Relationships + listing: Mapped["DomainListing"] = relationship("DomainListing", back_populates="inquiries") + + def __repr__(self) -> str: + return f"" + + +class ListingView(Base): + """ + Track listing page views for analytics. + """ + + __tablename__ = "listing_views" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False) + + # Visitor info + ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) + user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + referrer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + + # User (if logged in) + user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True) + + # Timestamp + viewed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + def __repr__(self) -> str: + return f"" + diff --git a/backend/app/models/sniper_alert.py b/backend/app/models/sniper_alert.py new file mode 100644 index 0000000..f71c737 --- /dev/null +++ b/backend/app/models/sniper_alert.py @@ -0,0 +1,183 @@ +""" +Sniper Alert models for hyper-personalized auction alerts. + +This implements "Strategie 4: Alerts nach Maß" from analysis_3.md: +"Der User kann extrem spezifische Filter speichern: +- Informiere mich NUR, wenn eine 4-Letter .com Domain droppt, die kein 'q' oder 'x' enthält. +- Informiere mich, wenn eine .ch Domain droppt, die das Wort 'Immo' enthält." + +DATABASE TABLES TO CREATE: +1. sniper_alerts - Saved filter configurations +2. sniper_alert_matches - Matched auctions for each alert +3. sniper_alert_notifications - Sent notifications + +Run migrations: alembic upgrade head +""" +from datetime import datetime +from typing import Optional, List +from sqlalchemy import String, DateTime, Float, Integer, Text, ForeignKey, Boolean, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + + +class SniperAlert(Base): + """ + Saved filter for hyper-personalized auction alerts. + + Users can define very specific criteria and get notified + when matching domains appear in auctions. + + Example filters: + - "4-letter .com without q or x" + - ".ch domains containing 'immo'" + - "Auctions under $100 ending in 1 hour" + + From analysis_3.md: + "Wenn die SMS/Mail kommt, weiß der User: Das ist relevant." + """ + + __tablename__ = "sniper_alerts" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False) + + # Alert name + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + + # Filter criteria (stored as JSON for flexibility) + # Example: {"tlds": ["com", "io"], "max_length": 4, "exclude_chars": ["q", "x"]} + filter_criteria: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + + # Individual filter fields (for database queries) + tlds: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Comma-separated: "com,io,ai" + keywords: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Must contain + exclude_keywords: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Must not contain + max_length: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + min_length: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + max_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + min_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + max_bids: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Low competition + ending_within_hours: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Urgency + platforms: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) # Comma-separated + + # Advanced filters + no_numbers: Mapped[bool] = mapped_column(Boolean, default=False) + no_hyphens: Mapped[bool] = mapped_column(Boolean, default=False) + exclude_chars: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # "q,x,z" + + # Notification settings + notify_email: Mapped[bool] = mapped_column(Boolean, default=True) + notify_sms: Mapped[bool] = mapped_column(Boolean, default=False) # Tycoon feature + notify_push: Mapped[bool] = mapped_column(Boolean, default=False) + + # Frequency limits + max_notifications_per_day: Mapped[int] = mapped_column(Integer, default=10) + cooldown_minutes: Mapped[int] = mapped_column(Integer, default=30) # Min time between alerts + + # Status + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + + # Stats + matches_count: Mapped[int] = mapped_column(Integer, default=0) + notifications_sent: Mapped[int] = mapped_column(Integer, default=0) + last_matched_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + last_notified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user: Mapped["User"] = relationship("User", back_populates="sniper_alerts") + matches: Mapped[List["SniperAlertMatch"]] = relationship( + "SniperAlertMatch", back_populates="alert", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + def matches_domain(self, domain: str, tld: str, price: float, num_bids: int) -> bool: + """Check if a domain matches this alert's criteria.""" + name = domain.split('.')[0] if '.' in domain else domain + + # TLD filter + if self.tlds: + allowed_tlds = [t.strip().lower() for t in self.tlds.split(',')] + if tld.lower() not in allowed_tlds: + return False + + # Length filters + if self.max_length and len(name) > self.max_length: + return False + if self.min_length and len(name) < self.min_length: + return False + + # Price filters + if self.max_price and price > self.max_price: + return False + if self.min_price and price < self.min_price: + return False + + # Competition filter + if self.max_bids and num_bids > self.max_bids: + return False + + # Keyword filters + if self.keywords: + required = [k.strip().lower() for k in self.keywords.split(',')] + if not any(kw in name.lower() for kw in required): + return False + + if self.exclude_keywords: + excluded = [k.strip().lower() for k in self.exclude_keywords.split(',')] + if any(kw in name.lower() for kw in excluded): + return False + + # Character filters + if self.no_numbers and any(c.isdigit() for c in name): + return False + + if self.no_hyphens and '-' in name: + return False + + if self.exclude_chars: + excluded_chars = [c.strip().lower() for c in self.exclude_chars.split(',')] + if any(c in name.lower() for c in excluded_chars): + return False + + return True + + +class SniperAlertMatch(Base): + """ + Record of a domain that matched a sniper alert. + """ + + __tablename__ = "sniper_alert_matches" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + alert_id: Mapped[int] = mapped_column(ForeignKey("sniper_alerts.id"), index=True, nullable=False) + + # Matched auction info + domain: Mapped[str] = mapped_column(String(255), nullable=False) + platform: Mapped[str] = mapped_column(String(50), nullable=False) + current_bid: Mapped[float] = mapped_column(Float, nullable=False) + end_time: Mapped[datetime] = mapped_column(DateTime, nullable=False) + auction_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + + # Status + notified: Mapped[bool] = mapped_column(Boolean, default=False) + clicked: Mapped[bool] = mapped_column(Boolean, default=False) + + # Timestamps + matched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + notified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + # Relationships + alert: Mapped["SniperAlert"] = relationship("SniperAlert", back_populates="matches") + + def __repr__(self) -> str: + return f"" + diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 20931d8..e5fa63b 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -60,6 +60,14 @@ class User(Base): price_alerts: Mapped[List["PriceAlert"]] = relationship( "PriceAlert", cascade="all, delete-orphan", passive_deletes=True ) + # For Sale Marketplace + listings: Mapped[List["DomainListing"]] = relationship( + "DomainListing", back_populates="user", cascade="all, delete-orphan" + ) + # Sniper Alerts + sniper_alerts: Mapped[List["SniperAlert"]] = relationship( + "SniperAlert", back_populates="user", cascade="all, delete-orphan" + ) def __repr__(self) -> str: return f"" diff --git a/frontend/src/app/buy/[slug]/page.tsx b/frontend/src/app/buy/[slug]/page.tsx new file mode 100644 index 0000000..fb9e03e --- /dev/null +++ b/frontend/src/app/buy/[slug]/page.tsx @@ -0,0 +1,460 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' +import { api } from '@/lib/api' +import { Header } from '@/components/Header' +import { Footer } from '@/components/Footer' +import { + Shield, + CheckCircle, + Clock, + DollarSign, + Mail, + User, + Building, + Phone, + MessageSquare, + Send, + Loader2, + AlertCircle, + Sparkles, + TrendingUp, + Globe, + Calendar, + ExternalLink, +} from 'lucide-react' +import Link from 'next/link' +import clsx from 'clsx' + +interface Listing { + domain: string + slug: string + title: string | null + description: string | null + asking_price: number | null + currency: string + price_type: string + pounce_score: number | null + estimated_value: number | null + is_verified: boolean + allow_offers: boolean + public_url: string + seller_verified: boolean + seller_member_since: string | null +} + +export default function BuyDomainPage() { + const params = useParams() + const slug = params.slug as string + + const [listing, setListing] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Inquiry form state + const [showForm, setShowForm] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [submitted, setSubmitted] = useState(false) + const [formData, setFormData] = useState({ + name: '', + email: '', + phone: '', + company: '', + message: '', + offer_amount: '', + }) + + useEffect(() => { + loadListing() + }, [slug]) + + const loadListing = async () => { + setLoading(true) + setError(null) + try { + const data = await api.request(`/listings/${slug}`) + setListing(data) + } catch (err: any) { + setError(err.message || 'Listing not found') + } finally { + setLoading(false) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSubmitting(true) + + try { + await api.request(`/listings/${slug}/inquire`, { + method: 'POST', + body: JSON.stringify({ + ...formData, + offer_amount: formData.offer_amount ? parseFloat(formData.offer_amount) : null, + }), + }) + setSubmitted(true) + } catch (err: any) { + setError(err.message || 'Failed to submit inquiry') + } finally { + setSubmitting(false) + } + } + + const formatPrice = (price: number, currency: string) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(price) + } + + const getScoreColor = (score: number) => { + if (score >= 80) return 'text-accent' + if (score >= 60) return 'text-amber-400' + return 'text-foreground-muted' + } + + if (loading) { + return ( +
+
+
+ ) + } + + if (error || !listing) { + return ( +
+
+
+
+ +

Domain Not Available

+

+ This listing may have been sold, removed, or doesn't exist. +

+ + Browse Listings + +
+
+
+
+ ) + } + + return ( +
+ {/* Background Effects */} +
+
+
+
+ +
+ +
+
+ {/* Domain Hero */} +
+ {listing.is_verified && ( +
+ + Verified Owner +
+ )} + +

+ {listing.domain} +

+ + {listing.title && ( +

+ {listing.title} +

+ )} + + {/* Price Badge */} +
+ {listing.asking_price ? ( + <> + + {listing.price_type === 'fixed' ? 'Price' : 'Asking'} + + + {formatPrice(listing.asking_price, listing.currency)} + + {listing.price_type === 'negotiable' && ( + + Negotiable + + )} + + ) : ( + <> + + Make an Offer + + )} +
+
+ +
+ {/* Main Content */} +
+ {/* Description */} + {listing.description && ( +
+

+ + About This Domain +

+

+ {listing.description} +

+
+ )} + + {/* Pounce Valuation */} + {listing.pounce_score && listing.estimated_value && ( +
+

+ + Pounce Valuation +

+
+
+

Domain Score

+

+ {listing.pounce_score} + /100 +

+
+
+

Estimated Value

+

+ {formatPrice(listing.estimated_value, listing.currency)} +

+
+
+

+ Valuation based on domain length, TLD, keywords, and market data. +

+
+ )} + + {/* Trust Indicators */} +
+
+
+ +
+
+

+ {listing.is_verified ? 'Verified' : 'Pending'} +

+

Ownership

+
+
+ +
+
+ +
+
+

+ .{listing.domain.split('.').pop()} +

+

Extension

+
+
+ + {listing.seller_member_since && ( +
+
+ +
+
+

+ {new Date(listing.seller_member_since).getFullYear()} +

+

Member Since

+
+
+ )} +
+
+ + {/* Sidebar - Contact Form */} +
+
+ {submitted ? ( +
+ +

Inquiry Sent!

+

+ The seller will respond to your message directly. +

+
+ ) : showForm ? ( +
+

Contact Seller

+ +
+ +
+ + setFormData({ ...formData, name: e.target.value })} + className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent" + placeholder="Your name" + /> +
+
+ +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent" + placeholder="your@email.com" + /> +
+
+ +
+ +
+ + setFormData({ ...formData, phone: e.target.value })} + className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent" + placeholder="+1 (555) 000-0000" + /> +
+
+ +
+ +
+ + setFormData({ ...formData, company: e.target.value })} + className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent" + placeholder="Your company" + /> +
+
+ + {listing.allow_offers && ( +
+ +
+ + setFormData({ ...formData, offer_amount: e.target.value })} + className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent" + placeholder="Amount in USD" + /> +
+
+ )} + +
+ +