fix: Improve watchlist monitoring to detect both status transitions
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled

- Detect when domains become TAKEN (not just when they become available)
- Send email notifications for both transitions
- Add "Refresh All" button to watchlist page
- Auto-refresh available domains for Tycoon users
- Add bulk refresh endpoint for watchlist
- Add warning toast type for status change alerts
- Better logging for domain status changes
This commit is contained in:
2025-12-18 10:28:47 +01:00
parent 460074d01f
commit 871ee3f80e
5 changed files with 315 additions and 20 deletions

View File

@ -240,7 +240,7 @@ async def refresh_domain(
current_user: CurrentUser,
db: Database,
):
"""Manually refresh domain availability status."""
"""Manually refresh domain availability status with a live check."""
result = await db.execute(
select(Domain).where(
Domain.id == domain_id,
@ -255,7 +255,10 @@ async def refresh_domain(
detail="Domain not found",
)
# Check domain
# Track previous state for logging
was_available = domain.is_available
# Check domain - always uses live data, no cache
check_result = await domain_checker.check_domain(domain.name)
# Update domain
@ -278,9 +281,97 @@ async def refresh_domain(
await db.commit()
await db.refresh(domain)
# Log status changes
if was_available != domain.is_available:
import logging
logger = logging.getLogger(__name__)
if was_available and not domain.is_available:
logger.info(f"Manual refresh: {domain.name} changed from AVAILABLE to TAKEN (registrar: {domain.registrar})")
else:
logger.info(f"Manual refresh: {domain.name} changed from TAKEN to AVAILABLE")
return domain
@router.post("/refresh-all")
async def refresh_all_domains(
current_user: CurrentUser,
db: Database,
):
"""
Refresh all domains in user's watchlist with live checks.
This is useful for bulk updates and to ensure all data is current.
Returns summary of changes detected.
"""
import logging
logger = logging.getLogger(__name__)
result = await db.execute(
select(Domain).where(Domain.user_id == current_user.id)
)
domains = result.scalars().all()
if not domains:
return {"message": "No domains to refresh", "checked": 0, "changes": []}
checked = 0
errors = 0
changes = []
for domain in domains:
try:
was_available = domain.is_available
was_registrar = domain.registrar
# Live check
check_result = await domain_checker.check_domain(domain.name)
# Track changes
if was_available != check_result.is_available:
change_type = "became_available" if check_result.is_available else "became_taken"
changes.append({
"domain": domain.name,
"change": change_type,
"old_registrar": was_registrar,
"new_registrar": check_result.registrar,
})
logger.info(f"Bulk refresh: {domain.name} {change_type}")
# Update domain
domain.status = check_result.status
domain.is_available = check_result.is_available
domain.registrar = check_result.registrar
domain.expiration_date = check_result.expiration_date
domain.last_checked = datetime.utcnow()
# Create check record
check = DomainCheck(
domain_id=domain.id,
status=check_result.status,
is_available=check_result.is_available,
response_data=str(check_result.to_dict()),
checked_at=datetime.utcnow(),
)
db.add(check)
checked += 1
except Exception as e:
logger.error(f"Error refreshing {domain.name}: {e}")
errors += 1
await db.commit()
return {
"message": f"Refreshed {checked} domains",
"checked": checked,
"errors": errors,
"changes": changes,
"total_domains": len(domains),
}
class NotifyUpdate(BaseModel):
"""Schema for updating notification settings."""
notify: bool

View File

@ -59,6 +59,10 @@ async def check_domains_by_frequency(frequency: str):
Args:
frequency: One of 'daily', 'hourly', 'realtime' (10-min)
This function now detects BOTH transitions:
- taken -> available: Domain dropped, notify user to register
- available -> taken: Domain was registered, notify user they missed it
"""
logger.info(f"Starting {frequency} domain check...")
start_time = datetime.utcnow()
@ -101,27 +105,51 @@ async def check_domains_by_frequency(frequency: str):
checked = 0
errors = 0
newly_available = []
newly_taken = [] # Track domains that became taken
status_changes = [] # All status changes for logging
for domain in domains:
try:
# Check domain availability
check_result = await domain_checker.check_domain(domain.name)
# Track if domain became available
was_taken = not domain.is_available
# Track status transitions
was_available = domain.is_available
is_now_available = check_result.is_available
if was_taken and is_now_available and domain.notify_on_available:
newly_available.append(domain)
# Detect transition: taken -> available (domain dropped!)
if not was_available and is_now_available:
status_changes.append({
'domain': domain.name,
'change': 'became_available',
'old_registrar': domain.registrar,
})
if domain.notify_on_available:
newly_available.append(domain)
logger.info(f"🎯 Domain AVAILABLE: {domain.name} (was registered by {domain.registrar})")
# Update domain
# Detect transition: available -> taken (someone registered it!)
elif was_available and not is_now_available:
status_changes.append({
'domain': domain.name,
'change': 'became_taken',
'new_registrar': check_result.registrar,
})
if domain.notify_on_available: # Notify if alerts are on
newly_taken.append({
'domain': domain,
'registrar': check_result.registrar,
})
logger.info(f"⚠️ Domain TAKEN: {domain.name} (now registered by {check_result.registrar})")
# Update domain with fresh data
domain.status = check_result.status
domain.is_available = check_result.is_available
domain.registrar = check_result.registrar
domain.expiration_date = check_result.expiration_date
domain.last_checked = datetime.utcnow()
# Create check record
# Create check record for history
check = DomainCheck(
domain_id=domain.id,
status=check_result.status,
@ -145,13 +173,19 @@ async def check_domains_by_frequency(frequency: str):
elapsed = (datetime.utcnow() - start_time).total_seconds()
logger.info(
f"Domain check complete. Checked: {checked}, Errors: {errors}, "
f"Newly available: {len(newly_available)}, Time: {elapsed:.2f}s"
f"Newly available: {len(newly_available)}, Newly taken: {len(newly_taken)}, "
f"Total changes: {len(status_changes)}, Time: {elapsed:.2f}s"
)
# 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_domain_availability_alerts(db, newly_available)
# Send notifications for domains that got taken (user missed them!)
if newly_taken:
logger.info(f"Domains that were taken: {[d['domain'].name for d in newly_taken]}")
await send_domain_taken_alerts(db, newly_taken)
async def check_all_domains():
@ -766,6 +800,77 @@ async def send_domain_availability_alerts(db, domains: list[Domain]):
logger.info(f"Sent {alerts_sent} domain availability alerts")
async def send_domain_taken_alerts(db, taken_domains: list[dict]):
"""
Send email alerts when watched available domains get registered by someone.
This notifies users that a domain they were watching (and was available)
has now been taken - either by them or someone else.
Args:
db: Database session
taken_domains: List of dicts with 'domain' (Domain object) and 'registrar' (str)
"""
if not email_service.is_configured():
logger.info("Email service not configured, skipping domain taken alerts")
return
alerts_sent = 0
for item in taken_domains:
domain = item['domain']
registrar = item.get('registrar') or 'Unknown registrar'
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:
# Send notification that the domain is no longer available
success = await email_service.send_email(
to_email=user.email,
subject=f"⚡ Domain Update: {domain.name} was registered",
html_content=f"""
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Domain Status Changed
</h2>
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
A domain on your watchlist has been registered:
</p>
<div style="margin: 24px 0; padding: 20px; background: #f8f8f8; border-radius: 6px; border-left: 3px solid #f59e0b;">
<p style="margin: 0 0 8px 0; font-size: 18px; font-weight: bold; font-family: monospace;">
{domain.name}
</p>
<p style="margin: 0; font-size: 14px; color: #666;">
Registrar: {registrar}
</p>
</div>
<p style="margin: 24px 0 0 0; font-size: 14px; color: #666666;">
If you registered this domain yourself, congratulations! 🎉<br>
If not, the domain might become available again in the future.
</p>
<p style="margin: 16px 0 0 0;">
<a href="https://pounce.ch/terminal/watchlist"
style="color: #000; text-decoration: underline;">
View your watchlist
</a>
</p>
""",
text_content=f"Domain {domain.name} on your watchlist was registered by {registrar}."
)
if success:
alerts_sent += 1
logger.info(f"📧 Domain taken alert sent for {domain.name} to {user.email}")
except Exception as e:
logger.error(f"Failed to send domain taken alert for {domain.name}: {e}")
logger.info(f"Sent {alerts_sent} domain taken alerts")
async def check_price_changes():
"""Check for TLD price changes and send alerts."""
logger.info("Checking for TLD price changes...")

View File

@ -152,12 +152,14 @@ export default function WatchlistPage() {
// Modal state
const [showAddModal, setShowAddModal] = useState(false)
const [refreshingId, setRefreshingId] = useState<number | null>(null)
const [refreshingAll, setRefreshingAll] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [togglingNotifyId, setTogglingNotifyId] = useState<number | null>(null)
const [healthReports, setHealthReports] = useState<Record<number, DomainHealthReport>>({})
const [loadingHealth, setLoadingHealth] = useState<Record<number, boolean>>({})
const [selectedDomain, setSelectedDomain] = useState<number | null>(null)
const [filter, setFilter] = useState<'all' | 'available' | 'expiring'>('all')
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null)
// Sorting
const [sortField, setSortField] = useState<'domain' | 'status' | 'health' | 'expiry'>('domain')
@ -262,10 +264,67 @@ export default function WatchlistPage() {
try {
await refreshDomain(id)
showToast('Intel updated', 'success')
setLastRefreshTime(new Date())
} catch { showToast('Update failed', 'error') }
finally { setRefreshingId(null) }
}, [refreshDomain, showToast])
// Refresh All Domains
const handleRefreshAll = useCallback(async () => {
if (refreshingAll) return
setRefreshingAll(true)
try {
const result = await api.refreshAllDomains()
// Refresh the domain list to get updated data
await checkAuth()
// Show appropriate message based on changes
if (result.changes.length > 0) {
const takenCount = result.changes.filter(c => c.change === 'became_taken').length
const availableCount = result.changes.filter(c => c.change === 'became_available').length
if (takenCount > 0 && availableCount > 0) {
showToast(`${result.checked} domains checked. ${availableCount} became available, ${takenCount} were taken!`, 'success')
} else if (takenCount > 0) {
showToast(`${result.checked} domains checked. ${takenCount} domain(s) were registered!`, 'warning')
} else if (availableCount > 0) {
showToast(`${result.checked} domains checked. ${availableCount} domain(s) are now available!`, 'success')
}
} else {
showToast(`All ${result.checked} domains checked. No status changes.`, 'success')
}
setLastRefreshTime(new Date())
} catch (err: any) {
showToast(err.message || 'Refresh failed', 'error')
} finally {
setRefreshingAll(false)
}
}, [refreshingAll, checkAuth, showToast])
// Auto-refresh available domains every 2 minutes for Tycoon users
useEffect(() => {
const hasAvailableDomains = domains?.some(d => d.is_available)
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
const isTycoon = tierName.toLowerCase() === 'tycoon'
// Only auto-refresh if user is Tycoon and has available domains
if (!isTycoon || !hasAvailableDomains) return
const interval = setInterval(async () => {
// Silently refresh all available domains to catch status changes
try {
await api.refreshAllDomains()
await checkAuth() // Refresh domain list
} catch {
// Silent fail - don't show error for background refresh
}
}, 2 * 60 * 1000) // 2 minutes
return () => clearInterval(interval)
}, [domains, subscription, checkAuth])
const handleDelete = useCallback(async (id: number, name: string) => {
if (!confirm(`Drop target: ${name}?`)) return
setDeletingId(id)
@ -372,13 +431,22 @@ export default function WatchlistPage() {
<Eye className="w-4 h-4 text-accent" />
<span className="text-sm font-mono text-white font-bold">Watchlist</span>
</div>
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent text-black text-[10px] font-bold uppercase"
>
<Plus className="w-3.5 h-3.5" />
Add
</button>
<div className="flex items-center gap-2">
<button
onClick={handleRefreshAll}
disabled={refreshingAll || !stats.total}
className="flex items-center gap-1 px-2 py-1.5 border border-white/10 text-white/60 text-[10px] font-bold uppercase disabled:opacity-50"
>
<RefreshCw className={clsx("w-3 h-3", refreshingAll && "animate-spin")} />
</button>
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent text-black text-[10px] font-bold uppercase"
>
<Plus className="w-3.5 h-3.5" />
Add
</button>
</div>
</div>
{/* Stats Grid */}
@ -431,7 +499,16 @@ export default function WatchlistPage() {
<div className="text-2xl font-bold text-orange-400 font-mono">{stats.expiring}</div>
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Expiring</div>
</div>
<div className="pl-6 border-l border-white/10">
<div className="pl-6 border-l border-white/10 flex items-center gap-3">
<button
onClick={handleRefreshAll}
disabled={refreshingAll || !stats.total}
className="flex items-center gap-2 px-4 py-3 border border-white/10 text-white/60 text-xs font-bold uppercase tracking-wider hover:bg-white/5 hover:text-white transition-colors disabled:opacity-50"
title={lastRefreshTime ? `Last refresh: ${lastRefreshTime.toLocaleTimeString()}` : 'Refresh all domains'}
>
<RefreshCw className={clsx("w-4 h-4", refreshingAll && "animate-spin")} />
{refreshingAll ? 'Checking...' : 'Refresh All'}
</button>
<button
onClick={() => setShowAddModal(true)}
className="flex items-center gap-2 px-5 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white transition-colors"

View File

@ -1,10 +1,10 @@
'use client'
import { useEffect, useState } from 'react'
import { Check, X, AlertCircle, Info } from 'lucide-react'
import { Check, X, AlertCircle, Info, AlertTriangle } from 'lucide-react'
import clsx from 'clsx'
export type ToastType = 'success' | 'error' | 'info'
export type ToastType = 'success' | 'error' | 'info' | 'warning'
interface ToastProps {
message: string
@ -31,7 +31,7 @@ export function Toast({ message, type = 'success', duration = 4000, onClose }: T
setTimeout(onClose, 300)
}
const Icon = type === 'success' ? Check : type === 'error' ? AlertCircle : Info
const Icon = type === 'success' ? Check : type === 'error' ? AlertCircle : type === 'warning' ? AlertTriangle : Info
return (
<div
@ -40,6 +40,7 @@ export function Toast({ message, type = 'success', duration = 4000, onClose }: T
isLeaving ? "translate-y-2 opacity-0" : "translate-y-0 opacity-100",
type === 'success' && "bg-accent/10 border-accent/20",
type === 'error' && "bg-danger/10 border-danger/20",
type === 'warning' && "bg-amber-500/10 border-amber-500/20",
type === 'info' && "bg-foreground/5 border-border"
)}
>
@ -47,12 +48,14 @@ export function Toast({ message, type = 'success', duration = 4000, onClose }: T
"w-7 h-7 rounded-lg flex items-center justify-center",
type === 'success' && "bg-accent/20",
type === 'error' && "bg-danger/20",
type === 'warning' && "bg-amber-500/20",
type === 'info' && "bg-foreground/10"
)}>
<Icon className={clsx(
"w-4 h-4",
type === 'success' && "text-accent",
type === 'error' && "text-danger",
type === 'warning' && "text-amber-500",
type === 'info' && "text-foreground-muted"
)} />
</div>
@ -60,6 +63,7 @@ export function Toast({ message, type = 'success', duration = 4000, onClose }: T
"text-body-sm",
type === 'success' && "text-accent",
type === 'error' && "text-danger",
type === 'warning' && "text-amber-500",
type === 'info' && "text-foreground"
)}>{message}</p>
<button
@ -68,6 +72,7 @@ export function Toast({ message, type = 'success', duration = 4000, onClose }: T
"ml-2 p-1 rounded hover:bg-foreground/5 transition-colors",
type === 'success' && "text-accent/70 hover:text-accent",
type === 'error' && "text-danger/70 hover:text-danger",
type === 'warning' && "text-amber-500/70 hover:text-amber-500",
type === 'info' && "text-foreground-muted hover:text-foreground"
)}
>

View File

@ -526,6 +526,23 @@ class ApiClient {
})
}
async refreshAllDomains() {
return this.request<{
message: string
checked: number
errors: number
changes: Array<{
domain: string
change: 'became_available' | 'became_taken'
old_registrar?: string
new_registrar?: string
}>
total_domains: number
}>('/domains/refresh-all', {
method: 'POST',
})
}
async updateDomainNotify(id: number, notify: boolean) {
return this.request<{
id: number