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
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:
@ -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
|
||||
|
||||
@ -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...")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
)}
|
||||
>
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user