feat: add email notifications and price change alerts

New features:
- Email service for domain availability alerts
- Price tracker for detecting significant price changes (>5%)
- Automated email notifications for:
  - Domain becomes available
  - TLD price changes
  - Weekly digest summaries
- New scheduler job for price change alerts (04:00 UTC)

Updated documentation:
- README: email notifications section, new services
- DEPLOYMENT: email setup, troubleshooting, scheduler jobs

New files:
- backend/app/services/email_service.py
- backend/app/services/price_tracker.py
This commit is contained in:
yves.gugger
2025-12-08 09:27:03 +01:00
parent 5ac5643cd2
commit fb080375a0
5 changed files with 863 additions and 3 deletions

View File

@ -249,3 +249,177 @@ cp backend/domainwatch.db backup/domainwatch_$(date +%Y%m%d).db
pg_dump -U pounce pounce > backup/pounce_$(date +%Y%m%d).sql
```
### TLD Price Data
```bash
# Export current prices to JSON (for versioning)
cd backend
python scripts/export_tld_prices.py
# The file is saved to: scripts/tld_prices_export.json
```
---
## Initial Data Seeding
### First-time Setup on New Server
After installing and starting the backend, seed the TLD price database:
```bash
cd backend
source venv/bin/activate
# Option A: Scrape fresh data from Porkbun API (recommended)
python scripts/seed_tld_prices.py
# Option B: Import from JSON backup
python scripts/import_tld_prices.py scripts/tld_prices_export.json
```
### Verify Data
```bash
# Check database stats
curl http://localhost:8000/api/v1/admin/tld-prices/stats
# Expected response:
# {
# "total_records": 886,
# "unique_tlds": 886,
# "unique_registrars": 1
# }
```
---
## Scheduled Jobs (Cronjobs)
The backend automatically runs these scheduled jobs:
| Job | Schedule | Description |
|-----|----------|-------------|
| Domain Check | Configurable | Check all watched domains for availability |
| TLD Price Scrape | 03:00 UTC daily | Scrape latest prices from Porkbun API |
| Price Change Alerts | 04:00 UTC daily | Send email alerts for significant price changes (>5%) |
### Verify Scheduler is Running
```bash
# Check backend logs
docker-compose logs backend | grep -i scheduler
# Or for systemd:
sudo journalctl -u pounce-backend | grep -i scheduler
```
### Manual Trigger
```bash
# Trigger TLD scrape manually
curl -X POST http://localhost:8000/api/v1/admin/scrape-tld-prices
# Check price stats
curl http://localhost:8000/api/v1/admin/tld-prices/stats
```
---
## Email Notifications (Optional)
To enable email notifications for domain availability and price changes:
### 1. Configure SMTP in `.env`
```env
SMTP_HOST=smtp.your-provider.com
SMTP_PORT=587
SMTP_USER=your-email@domain.com
SMTP_PASSWORD=your-app-password
```
### 2. Supported Email Providers
| Provider | SMTP Host | Port | Notes |
|----------|-----------|------|-------|
| Gmail | smtp.gmail.com | 587 | Requires App Password |
| SendGrid | smtp.sendgrid.net | 587 | Use API key as password |
| Mailgun | smtp.mailgun.org | 587 | |
| Amazon SES | email-smtp.region.amazonaws.com | 587 | |
### 3. Test Email
```bash
# Test via API (create this endpoint if needed)
curl -X POST http://localhost:8000/api/v1/admin/test-email?to=you@email.com
```
### Email Types
1. **Domain Available Alert** — When a watched domain becomes available
2. **Price Change Alert** — When TLD price changes >5%
3. **Weekly Digest** — Summary of watched domains and price trends
---
## Troubleshooting
### TLD Prices Not Loading
1. Check if database has data:
```bash
curl http://localhost:8000/api/v1/admin/tld-prices/stats
```
2. If `total_records: 0`, run seed script:
```bash
cd backend && python scripts/seed_tld_prices.py
```
3. Check Porkbun API is accessible:
```bash
curl -X POST https://api.porkbun.com/api/json/v3/pricing/get -d '{}'
```
### .ch Domains Not Working
Swiss (.ch) and Liechtenstein (.li) domains use a special RDAP endpoint:
```bash
# Test directly
curl https://rdap.nic.ch/domain/example.ch
```
The domain checker automatically uses this endpoint for .ch/.li domains.
### Email Not Sending
1. Check SMTP config in `.env`
2. Verify logs:
```bash
docker-compose logs backend | grep -i email
```
3. Test SMTP connection:
```python
import smtplib
server = smtplib.SMTP('smtp.host.com', 587)
server.starttls()
server.login('user', 'pass')
```
### Scheduler Not Running
1. Check if backend started correctly:
```bash
curl http://localhost:8000/health
```
2. View scheduler logs:
```bash
docker-compose logs backend | grep -i "scheduler\|job"
```
3. Verify jobs are registered:
- Should see "Scheduler configured" in startup logs

View File

@ -8,10 +8,12 @@ A professional full-stack application for monitoring domain name availability wi
- **Domain Availability Monitoring** — Track any domain and get notified when it becomes available
- **TLD Price Intelligence** — Compare prices across 886+ TLDs from Porkbun API
- **Automated Price Scraping** — Daily cronjob scrapes real TLD prices from public APIs
- **Price Change Alerts** — Email notifications when TLD prices change >5%
- **Expiration Tracking** — Monitor domain expiration dates and plan ahead
- **Real-time Checks** — RDAP, WHOIS, and DNS-based availability verification
- **Swiss Domain Support** — Special RDAP integration for .ch/.li domains via nic.ch
- **Historical Data** — Track price trends and availability history over time (12 months)
- **Email Notifications** — Domain available alerts, price changes, weekly digests
### User Experience
- **Modern UI** — Clean, minimalist dark-mode design with smooth animations
@ -72,6 +74,8 @@ pounce/
│ │ ├── services/ # Business logic
│ │ │ ├── auth.py # Auth service (JWT, hashing)
│ │ │ ├── domain_checker.py # RDAP/WHOIS/DNS checks
│ │ │ ├── email_service.py # Email notifications (SMTP)
│ │ │ ├── price_tracker.py # Price change detection & alerts
│ │ │ └── tld_scraper/ # TLD price scraping
│ │ │ ├── __init__.py
│ │ │ ├── base.py # Base scraper class
@ -82,6 +86,11 @@ pounce/
│ │ ├── database.py # Database connection
│ │ ├── main.py # FastAPI app entry
│ │ └── scheduler.py # Background jobs
│ ├── scripts/ # Utility scripts
│ │ ├── seed_tld_prices.py # Initial TLD data scrape
│ │ ├── export_tld_prices.py # Export DB to JSON
│ │ ├── import_tld_prices.py # Import JSON to DB
│ │ └── tld_prices_export.json # Price data backup
│ ├── requirements.txt
│ ├── Dockerfile
│ └── env.example
@ -360,6 +369,7 @@ POST https://api.porkbun.com/api/json/v3/pricing/get
|-----|----------|-------------|
| **Domain Check** | Configurable (default: daily) | Checks all watched domains |
| **TLD Price Scrape** | Daily at 03:00 UTC | Scrapes current TLD prices |
| **Price Change Alerts** | Daily at 04:00 UTC | Sends email for price changes >5% |
### Manual Scrape
@ -423,6 +433,59 @@ The scraper architecture supports multiple sources:
---
## Email Notifications
pounce supports automated email notifications for domain availability and price changes.
### Setup
Configure SMTP settings in your `.env`:
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
```
### Notification Types
| Type | Trigger | Description |
|------|---------|-------------|
| **Domain Available** | Watched domain becomes available | Instant alert to domain owner |
| **Price Change Alert** | TLD price changes >5% | Daily batch after price scrape |
| **Weekly Digest** | Every Sunday | Summary of watched domains & price trends |
### Email Templates
All emails feature:
- Dark mode branded design matching the app
- Clear call-to-action buttons
- Direct links to register domains
- Unsubscribe options
### Test Email
```bash
# Via Python
cd backend && python3 -c "
import asyncio
from app.services.email_service import email_service
async def test():
result = await email_service.send_domain_available_alert(
'test@example.com',
'example.com',
'Test User'
)
print('Email sent:', result)
asyncio.run(test())
"
```
---
## Design System
### Colors

View File

@ -1,4 +1,4 @@
"""Background scheduler for daily domain checks and TLD price scraping."""
"""Background scheduler for domain checks, TLD price scraping, and notifications."""
import asyncio
import logging
from datetime import datetime
@ -10,7 +10,10 @@ from sqlalchemy import select
from app.config import get_settings
from app.database import AsyncSessionLocal
from app.models.domain import Domain, DomainCheck
from app.models.user import User
from app.services.domain_checker import domain_checker
from app.services.email_service import email_service
from app.services.price_tracker import price_tracker
logger = logging.getLogger(__name__)
settings = get_settings()
@ -103,10 +106,10 @@ async def check_all_domains():
f"Newly available: {len(newly_available)}, Time: {elapsed:.2f}s"
)
# TODO: Send notifications for newly available domains
# Send notifications for newly available domains
if newly_available:
logger.info(f"Domains that became available: {[d.name for d in newly_available]}")
# await send_availability_notifications(newly_available)
await send_domain_availability_alerts(db, newly_available)
def setup_scheduler():
@ -129,10 +132,20 @@ def setup_scheduler():
replace_existing=True,
)
# Price change check at 04:00 UTC (after scrape completes)
scheduler.add_job(
check_price_changes,
CronTrigger(hour=4, minute=0),
id="daily_price_check",
name="Daily Price Change Check",
replace_existing=True,
)
logger.info(
f"Scheduler configured:"
f"\n - Domain check at {settings.check_hour:02d}:{settings.check_minute:02d}"
f"\n - TLD price scrape at 03:00 UTC"
f"\n - Price change alerts at 04:00 UTC"
)
@ -160,3 +173,56 @@ async def run_manual_tld_scrape():
"""Run TLD price scrape manually (for testing or on-demand)."""
await scrape_tld_prices()
async def send_domain_availability_alerts(db, domains: list[Domain]):
"""Send email alerts for newly available domains."""
if not email_service.is_enabled:
logger.info("Email service not configured, skipping domain alerts")
return
alerts_sent = 0
for domain in domains:
try:
# Get domain owner
result = await db.execute(
select(User).where(User.id == domain.user_id)
)
user = result.scalar_one_or_none()
if user and user.email:
success = await email_service.send_domain_available_alert(
to_email=user.email,
domain=domain.name,
user_name=user.name,
)
if success:
alerts_sent += 1
except Exception as e:
logger.error(f"Failed to send alert for {domain.name}: {e}")
logger.info(f"Sent {alerts_sent} domain availability alerts")
async def check_price_changes():
"""Check for TLD price changes and send alerts."""
logger.info("Checking for TLD price changes...")
try:
async with AsyncSessionLocal() as db:
# Detect changes in last 24 hours
changes = await price_tracker.detect_price_changes(db, hours=24)
if changes:
logger.info(f"Found {len(changes)} significant price changes")
# Send alerts (if email is configured)
alerts_sent = await price_tracker.send_price_alerts(db, changes)
logger.info(f"Sent {alerts_sent} price change alerts")
else:
logger.info("No significant price changes detected")
except Exception as e:
logger.exception(f"Price change check failed: {e}")

View File

@ -0,0 +1,303 @@
"""Email notification service for domain and price alerts."""
import logging
import asyncio
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Optional
from dataclasses import dataclass
import aiosmtplib
from app.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
@dataclass
class EmailConfig:
"""Email configuration."""
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
from_email: str = "noreply@pounce.dev"
from_name: str = "Pounce Alerts"
class EmailService:
"""
Async email service for sending notifications.
Supports:
- Domain availability alerts
- Price change notifications
- Weekly digest emails
"""
def __init__(self, config: EmailConfig = None):
"""Initialize email service."""
self.config = config or EmailConfig(
smtp_host=getattr(settings, 'smtp_host', ''),
smtp_port=getattr(settings, 'smtp_port', 587),
smtp_user=getattr(settings, 'smtp_user', ''),
smtp_password=getattr(settings, 'smtp_password', ''),
)
self._enabled = bool(self.config.smtp_host and self.config.smtp_user)
@property
def is_enabled(self) -> bool:
"""Check if email service is configured."""
return self._enabled
async def send_email(
self,
to_email: str,
subject: str,
html_body: str,
text_body: str = None,
) -> bool:
"""
Send an email.
Args:
to_email: Recipient email address
subject: Email subject
html_body: HTML content
text_body: Plain text content (optional)
Returns:
True if sent successfully, False otherwise
"""
if not self.is_enabled:
logger.warning("Email service not configured, skipping send")
return False
try:
message = MIMEMultipart("alternative")
message["From"] = f"{self.config.from_name} <{self.config.from_email}>"
message["To"] = to_email
message["Subject"] = subject
# Add text and HTML parts
if text_body:
message.attach(MIMEText(text_body, "plain"))
message.attach(MIMEText(html_body, "html"))
# Send via SMTP
await aiosmtplib.send(
message,
hostname=self.config.smtp_host,
port=self.config.smtp_port,
username=self.config.smtp_user,
password=self.config.smtp_password,
use_tls=True,
)
logger.info(f"Email sent to {to_email}: {subject}")
return True
except Exception as e:
logger.error(f"Failed to send email to {to_email}: {e}")
return False
async def send_domain_available_alert(
self,
to_email: str,
domain: str,
user_name: str = None,
) -> bool:
"""Send alert when a watched domain becomes available."""
subject = f"🎉 Domain Available: {domain}"
html_body = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #fafafa; padding: 40px; }}
.container {{ max-width: 600px; margin: 0 auto; background: #111; border-radius: 12px; padding: 32px; }}
.logo {{ color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }}
.title {{ font-size: 28px; margin-bottom: 16px; }}
.domain {{ color: #00d4aa; font-size: 32px; font-weight: bold; background: #0a0a0a; padding: 16px 24px; border-radius: 8px; display: inline-block; margin: 16px 0; }}
.cta {{ display: inline-block; background: #00d4aa; color: #0a0a0a; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; margin-top: 24px; }}
.footer {{ margin-top: 32px; padding-top: 24px; border-top: 1px solid #333; color: #888; font-size: 14px; }}
</style>
</head>
<body>
<div class="container">
<div class="logo">• pounce</div>
<h1 class="title">Great news{f', {user_name}' if user_name else ''}!</h1>
<p>A domain you're watching just became available:</p>
<div class="domain">{domain}</div>
<p>This is your chance to register it before someone else does!</p>
<a href="https://porkbun.com/checkout/search?q={domain}" class="cta">Register Now →</a>
<div class="footer">
<p>You're receiving this because you added {domain} to your watchlist on Pounce.</p>
<p>— The Pounce Team</p>
</div>
</div>
</body>
</html>
"""
text_body = f"""
Great news{f', {user_name}' if user_name else ''}!
A domain you're watching just became available: {domain}
This is your chance to register it before someone else does!
Register now: https://porkbun.com/checkout/search?q={domain}
— The Pounce Team
"""
return await self.send_email(to_email, subject, html_body, text_body)
async def send_price_change_alert(
self,
to_email: str,
tld: str,
old_price: float,
new_price: float,
change_percent: float,
registrar: str = "average",
) -> bool:
"""Send alert when TLD price changes significantly."""
direction = "📈 increased" if new_price > old_price else "📉 decreased"
color = "#f97316" if new_price > old_price else "#00d4aa"
subject = f"TLD Price Alert: .{tld} {direction} by {abs(change_percent):.1f}%"
html_body = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #fafafa; padding: 40px; }}
.container {{ max-width: 600px; margin: 0 auto; background: #111; border-radius: 12px; padding: 32px; }}
.logo {{ color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }}
.title {{ font-size: 24px; margin-bottom: 16px; }}
.tld {{ color: {color}; font-size: 48px; font-weight: bold; }}
.price-box {{ background: #0a0a0a; padding: 24px; border-radius: 8px; margin: 24px 0; }}
.price {{ font-size: 24px; }}
.old {{ color: #888; text-decoration: line-through; }}
.new {{ color: {color}; font-weight: bold; }}
.change {{ color: {color}; font-size: 20px; margin-top: 8px; }}
.footer {{ margin-top: 32px; padding-top: 24px; border-top: 1px solid #333; color: #888; font-size: 14px; }}
</style>
</head>
<body>
<div class="container">
<div class="logo">• pounce</div>
<h1 class="title">TLD Price Change Alert</h1>
<div class="tld">.{tld}</div>
<div class="price-box">
<div class="price">
<span class="old">${old_price:.2f}</span> →
<span class="new">${new_price:.2f}</span>
</div>
<div class="change">{change_percent:+.1f}% ({registrar})</div>
</div>
<p>{"Now might be a good time to register!" if new_price < old_price else "Prices have gone up - act fast if you need this TLD."}</p>
<div class="footer">
<p>You're subscribed to TLD price alerts on Pounce.</p>
<p>— The Pounce Team</p>
</div>
</div>
</body>
</html>
"""
text_body = f"""
TLD Price Change Alert
.{tld} has {direction} by {abs(change_percent):.1f}%
Old price: ${old_price:.2f}
New price: ${new_price:.2f}
Source: {registrar}
{"Now might be a good time to register!" if new_price < old_price else "Prices have gone up."}
— The Pounce Team
"""
return await self.send_email(to_email, subject, html_body, text_body)
async def send_weekly_digest(
self,
to_email: str,
user_name: str,
watched_domains: list[dict],
price_changes: list[dict],
) -> bool:
"""Send weekly summary email."""
subject = "📊 Your Weekly Pounce Digest"
# Build domain status HTML
domains_html = ""
for d in watched_domains[:10]:
status_color = "#00d4aa" if d.get("is_available") else "#888"
status_text = "Available!" if d.get("is_available") else "Taken"
domains_html += f'<tr><td style="padding: 8px 0;">{d["domain"]}</td><td style="color: {status_color};">{status_text}</td></tr>'
# Build price changes HTML
prices_html = ""
for p in price_changes[:5]:
change = p.get("change_percent", 0)
color = "#00d4aa" if change < 0 else "#f97316"
prices_html += f'<tr><td style="padding: 8px 0;">.{p["tld"]}</td><td style="color: {color};">{change:+.1f}%</td><td>${p.get("new_price", 0):.2f}</td></tr>'
html_body = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0a0a0a; color: #fafafa; padding: 40px; }}
.container {{ max-width: 600px; margin: 0 auto; background: #111; border-radius: 12px; padding: 32px; }}
.logo {{ color: #00d4aa; font-size: 24px; font-weight: bold; margin-bottom: 24px; }}
.section {{ margin: 24px 0; }}
.section-title {{ font-size: 18px; color: #888; margin-bottom: 12px; }}
table {{ width: 100%; border-collapse: collapse; }}
th {{ text-align: left; color: #888; font-weight: normal; padding: 8px 0; border-bottom: 1px solid #333; }}
.footer {{ margin-top: 32px; padding-top: 24px; border-top: 1px solid #333; color: #888; font-size: 14px; }}
</style>
</head>
<body>
<div class="container">
<div class="logo">• pounce</div>
<h1>Weekly Digest</h1>
<p>Hi {user_name}, here's your weekly summary:</p>
<div class="section">
<div class="section-title">Your Watched Domains</div>
<table>
<tr><th>Domain</th><th>Status</th></tr>
{domains_html if domains_html else '<tr><td colspan="2" style="color: #888;">No domains being watched</td></tr>'}
</table>
</div>
<div class="section">
<div class="section-title">Notable Price Changes</div>
<table>
<tr><th>TLD</th><th>Change</th><th>Price</th></tr>
{prices_html if prices_html else '<tr><td colspan="3" style="color: #888;">No significant changes this week</td></tr>'}
</table>
</div>
<div class="footer">
<p>— The Pounce Team</p>
</div>
</div>
</body>
</html>
"""
return await self.send_email(to_email, subject, html_body)
# Singleton instance
email_service = EmailService()

View File

@ -0,0 +1,254 @@
"""TLD Price change tracking and alerting service."""
import logging
from datetime import datetime, timedelta
from dataclasses import dataclass
from typing import Optional
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tld_price import TLDPrice
from app.models.user import User
from app.services.email_service import email_service
logger = logging.getLogger(__name__)
@dataclass
class PriceChange:
"""Represents a TLD price change."""
tld: str
registrar: str
old_price: float
new_price: float
change_amount: float
change_percent: float
detected_at: datetime
@property
def is_significant(self) -> bool:
"""Check if price change is significant (>5%)."""
return abs(self.change_percent) >= 5.0
@property
def direction(self) -> str:
"""Get price change direction."""
if self.change_percent > 0:
return "up"
elif self.change_percent < 0:
return "down"
return "stable"
class PriceTracker:
"""
Tracks TLD price changes and sends alerts.
Features:
- Detect significant price changes (>5%)
- Send email alerts to subscribed users
- Generate price change reports
"""
# Threshold for significant price change (percentage)
SIGNIFICANT_CHANGE_THRESHOLD = 5.0
async def detect_price_changes(
self,
db: AsyncSession,
hours: int = 24,
) -> list[PriceChange]:
"""
Detect price changes in the last N hours.
Args:
db: Database session
hours: Look back period in hours
Returns:
List of significant price changes
"""
changes = []
now = datetime.utcnow()
cutoff = now - timedelta(hours=hours)
# Get unique TLD/registrar combinations
tld_registrars = await db.execute(
select(TLDPrice.tld, TLDPrice.registrar)
.distinct()
)
for tld, registrar in tld_registrars:
# Get the two most recent prices for this TLD/registrar
result = await db.execute(
select(TLDPrice)
.where(
and_(
TLDPrice.tld == tld,
TLDPrice.registrar == registrar,
)
)
.order_by(TLDPrice.recorded_at.desc())
.limit(2)
)
prices = result.scalars().all()
if len(prices) < 2:
continue
new_price = prices[0]
old_price = prices[1]
# Check if change is within our time window
if new_price.recorded_at < cutoff:
continue
# Calculate change
if old_price.registration_price == 0:
continue
change_amount = new_price.registration_price - old_price.registration_price
change_percent = (change_amount / old_price.registration_price) * 100
# Only track significant changes
if abs(change_percent) >= self.SIGNIFICANT_CHANGE_THRESHOLD:
changes.append(PriceChange(
tld=tld,
registrar=registrar,
old_price=old_price.registration_price,
new_price=new_price.registration_price,
change_amount=change_amount,
change_percent=change_percent,
detected_at=new_price.recorded_at,
))
# Sort by absolute change percentage (most significant first)
changes.sort(key=lambda x: abs(x.change_percent), reverse=True)
logger.info(f"Detected {len(changes)} significant price changes in last {hours}h")
return changes
async def send_price_alerts(
self,
db: AsyncSession,
changes: list[PriceChange],
min_tier: str = "professional",
) -> int:
"""
Send price change alerts to subscribed users.
Args:
db: Database session
changes: List of price changes to alert about
min_tier: Minimum subscription tier to receive alerts
Returns:
Number of alerts sent
"""
if not changes:
return 0
if not email_service.is_enabled:
logger.warning("Email service not configured, skipping price alerts")
return 0
# Get users with appropriate subscription
# For now, send to all active users (can filter by tier later)
result = await db.execute(
select(User).where(User.is_active == True)
)
users = result.scalars().all()
alerts_sent = 0
for change in changes[:10]: # Limit to top 10 changes
for user in users:
try:
success = await email_service.send_price_change_alert(
to_email=user.email,
tld=change.tld,
old_price=change.old_price,
new_price=change.new_price,
change_percent=change.change_percent,
registrar=change.registrar,
)
if success:
alerts_sent += 1
except Exception as e:
logger.error(f"Failed to send alert to {user.email}: {e}")
logger.info(f"Sent {alerts_sent} price change alerts")
return alerts_sent
async def get_trending_changes(
self,
db: AsyncSession,
days: int = 7,
limit: int = 10,
) -> list[dict]:
"""
Get trending price changes for dashboard display.
Args:
db: Database session
days: Look back period
limit: Max results to return
Returns:
List of trending price changes
"""
changes = await self.detect_price_changes(db, hours=days * 24)
return [
{
"tld": c.tld,
"registrar": c.registrar,
"old_price": c.old_price,
"new_price": c.new_price,
"change_percent": round(c.change_percent, 2),
"direction": c.direction,
"detected_at": c.detected_at.isoformat(),
}
for c in changes[:limit]
]
async def generate_price_report(
self,
db: AsyncSession,
days: int = 30,
) -> dict:
"""Generate a comprehensive price change report."""
changes = await self.detect_price_changes(db, hours=days * 24)
increases = [c for c in changes if c.direction == "up"]
decreases = [c for c in changes if c.direction == "down"]
return {
"period_days": days,
"total_changes": len(changes),
"price_increases": len(increases),
"price_decreases": len(decreases),
"avg_increase": round(sum(c.change_percent for c in increases) / len(increases), 2) if increases else 0,
"avg_decrease": round(sum(c.change_percent for c in decreases) / len(decreases), 2) if decreases else 0,
"biggest_increase": {
"tld": increases[0].tld,
"change": round(increases[0].change_percent, 2),
} if increases else None,
"biggest_decrease": {
"tld": decreases[0].tld,
"change": round(decreases[0].change_percent, 2),
} if decreases else None,
"top_changes": [
{
"tld": c.tld,
"change_percent": round(c.change_percent, 2),
"new_price": c.new_price,
}
for c in changes[:10]
],
}
# Singleton instance
price_tracker = PriceTracker()