""" 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.", }