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
184 lines
7.5 KiB
Python
184 lines
7.5 KiB
Python
"""
|
|
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"<SniperAlert '{self.name}' (user={self.user_id})>"
|
|
|
|
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"<SniperAlertMatch {self.domain} for alert #{self.alert_id}>"
|
|
|