diff --git a/backend/app/api/domains.py b/backend/app/api/domains.py index 1b48dcf..0c0a229 100644 --- a/backend/app/api/domains.py +++ b/backend/app/api/domains.py @@ -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 diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 68d7b38..303df35 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -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""" +

+ Domain Status Changed +

+

+ A domain on your watchlist has been registered: +

+
+

+ {domain.name} +

+

+ Registrar: {registrar} +

+
+

+ If you registered this domain yourself, congratulations! 🎉
+ If not, the domain might become available again in the future. +

+

+ + View your watchlist + +

+ """, + 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...") diff --git a/frontend/src/app/terminal/watchlist/page.tsx b/frontend/src/app/terminal/watchlist/page.tsx index e695619..ed93b86 100755 --- a/frontend/src/app/terminal/watchlist/page.tsx +++ b/frontend/src/app/terminal/watchlist/page.tsx @@ -152,12 +152,14 @@ export default function WatchlistPage() { // Modal state const [showAddModal, setShowAddModal] = useState(false) const [refreshingId, setRefreshingId] = useState(null) + const [refreshingAll, setRefreshingAll] = useState(false) const [deletingId, setDeletingId] = useState(null) const [togglingNotifyId, setTogglingNotifyId] = useState(null) const [healthReports, setHealthReports] = useState>({}) const [loadingHealth, setLoadingHealth] = useState>({}) const [selectedDomain, setSelectedDomain] = useState(null) const [filter, setFilter] = useState<'all' | 'available' | 'expiring'>('all') + const [lastRefreshTime, setLastRefreshTime] = useState(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() { Watchlist - +
+ + +
{/* Stats Grid */} @@ -431,7 +499,16 @@ export default function WatchlistPage() {
{stats.expiring}
Expiring
-
+
+