From 7c08e90a56591ed126bd01a276ba74ea0378ad3b Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Sun, 21 Dec 2025 18:14:25 +0100 Subject: [PATCH] fix: normalize transition timestamps across terminal Convert timezone-aware datetimes to naive UTC before persisting (prevents Postgres 500s), add deletion_date migrations, and unify transition countdown + tracked-state across Drops, Watchlist, and Analyze panel. --- backend/app/api/domains.py | 16 ++---- backend/app/api/drops.py | 13 +++-- backend/app/db_migrations.py | 10 ++++ backend/app/services/domain_checker.py | 18 ++++--- backend/app/services/zone_file.py | 11 ++-- backend/app/utils/__init__.py | 2 + backend/app/utils/datetime.py | 34 ++++++++++++ frontend/src/app/terminal/watchlist/page.tsx | 30 +++++------ .../src/components/analyze/AnalyzePanel.tsx | 6 ++- frontend/src/components/hunt/DropsTab.tsx | 54 ++++++++++--------- frontend/src/lib/api.ts | 3 ++ frontend/src/lib/store.ts | 3 ++ frontend/src/lib/time.ts | 35 ++++++++++++ 13 files changed, 164 insertions(+), 71 deletions(-) create mode 100644 backend/app/utils/__init__.py create mode 100644 backend/app/utils/datetime.py create mode 100644 frontend/src/lib/time.ts diff --git a/backend/app/api/domains.py b/backend/app/api/domains.py index c8c9fb7..59c7bb7 100644 --- a/backend/app/api/domains.py +++ b/backend/app/api/domains.py @@ -1,6 +1,6 @@ """Domain management API (requires authentication).""" import json -from datetime import datetime, timezone +from datetime import datetime from math import ceil from fastapi import APIRouter, HTTPException, status, Query @@ -13,19 +13,11 @@ from app.models.subscription import TIER_CONFIG, SubscriptionTier from app.schemas.domain import DomainCreate, DomainResponse, DomainListResponse from app.services.domain_checker import domain_checker from app.services.domain_health import get_health_checker, HealthStatus +from app.utils.datetime import to_naive_utc router = APIRouter() -def _to_naive_utc(dt: datetime | None) -> datetime | None: - """Convert timezone-aware datetime to naive UTC datetime for PostgreSQL.""" - if dt is None: - return None - if dt.tzinfo is not None: - # Convert to UTC and remove timezone info - return dt.astimezone(timezone.utc).replace(tzinfo=None) - return dt - def _safe_json_loads(value: str | None, default): if not value: return default @@ -276,7 +268,7 @@ async def refresh_domain( domain.status = check_result.status domain.is_available = check_result.is_available domain.registrar = check_result.registrar - domain.expiration_date = _to_naive_utc(check_result.expiration_date) + domain.expiration_date = to_naive_utc(check_result.expiration_date) domain.last_checked = datetime.utcnow() domain.last_check_method = check_result.check_method @@ -354,7 +346,7 @@ async def refresh_all_domains( domain.status = check_result.status domain.is_available = check_result.is_available domain.registrar = check_result.registrar - domain.expiration_date = _to_naive_utc(check_result.expiration_date) + domain.expiration_date = to_naive_utc(check_result.expiration_date) domain.last_checked = datetime.utcnow() domain.last_check_method = check_result.check_method diff --git a/backend/app/api/drops.py b/backend/app/api/drops.py index c56f2f6..a214953 100644 --- a/backend/app/api/drops.py +++ b/backend/app/api/drops.py @@ -17,6 +17,7 @@ from sqlalchemy import select, update from app.database import get_db from app.api.deps import get_current_user from app.models.zone_file import DroppedDomain +from app.utils.datetime import to_iso_utc, to_naive_utc from app.services.zone_file import ( ZoneFileService, get_dropped_domains, @@ -213,6 +214,8 @@ async def api_check_drop_status( try: # Check with dedicated drop status checker status_result = await check_drop_status(full_domain) + + persisted_deletion_date = to_naive_utc(status_result.deletion_date) # Update the drop in DB await db.execute( @@ -222,7 +225,7 @@ async def api_check_drop_status( availability_status=status_result.status, rdap_status=str(status_result.rdap_status) if status_result.rdap_status else None, last_status_check=datetime.utcnow(), - deletion_date=status_result.deletion_date, + deletion_date=persisted_deletion_date, last_check_method=status_result.check_method, ) ) @@ -236,8 +239,8 @@ async def api_check_drop_status( "can_register_now": status_result.can_register_now, "should_track": status_result.should_monitor, "message": status_result.message, - "deletion_date": status_result.deletion_date.isoformat() if status_result.deletion_date else None, - "status_checked_at": datetime.utcnow().isoformat(), + "deletion_date": to_iso_utc(persisted_deletion_date), + "status_checked_at": to_iso_utc(datetime.utcnow()), "status_source": status_result.check_method, } @@ -303,8 +306,10 @@ async def api_track_drop( name=full_domain, status=domain_status, is_available=drop.availability_status == 'available', - deletion_date=drop.deletion_date, # Copy deletion date for countdown + deletion_date=to_naive_utc(drop.deletion_date), # Copy deletion date for countdown notify_on_available=True, # Enable notification! + last_checked=datetime.utcnow(), + last_check_method="zone_drop", ) db.add(domain) await db.commit() diff --git a/backend/app/db_migrations.py b/backend/app/db_migrations.py index 05921a5..4e28c16 100644 --- a/backend/app/db_migrations.py +++ b/backend/app/db_migrations.py @@ -109,10 +109,15 @@ async def apply_migrations(conn: AsyncConnection) -> None: # 2b) domains indexes (watchlist list/sort/filter) # --------------------------------------------------------- if await _table_exists(conn, "domains"): + dt_type = "DATETIME" if dialect == "sqlite" else "TIMESTAMP" + # Canonical status metadata (optional) if not await _has_column(conn, "domains", "last_check_method"): logger.info("DB migrations: adding column domains.last_check_method") await conn.execute(text("ALTER TABLE domains ADD COLUMN last_check_method VARCHAR(30)")) + if not await _has_column(conn, "domains", "deletion_date"): + logger.info("DB migrations: adding column domains.deletion_date") + await conn.execute(text(f"ALTER TABLE domains ADD COLUMN deletion_date {dt_type}")) await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_domains_user_id ON domains(user_id)")) await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_domains_status ON domains(status)")) @@ -135,9 +140,14 @@ async def apply_migrations(conn: AsyncConnection) -> None: # 2d) dropped_domains indexes + de-duplication # --------------------------------------------------------- if await _table_exists(conn, "dropped_domains"): + dt_type = "DATETIME" if dialect == "sqlite" else "TIMESTAMP" + if not await _has_column(conn, "dropped_domains", "last_check_method"): logger.info("DB migrations: adding column dropped_domains.last_check_method") await conn.execute(text("ALTER TABLE dropped_domains ADD COLUMN last_check_method VARCHAR(30)")) + if not await _has_column(conn, "dropped_domains", "deletion_date"): + logger.info("DB migrations: adding column dropped_domains.deletion_date") + await conn.execute(text(f"ALTER TABLE dropped_domains ADD COLUMN deletion_date {dt_type}")) # Query patterns: # - by time window (dropped_date) + optional tld + keyword diff --git a/backend/app/services/domain_checker.py b/backend/app/services/domain_checker.py index 9a1afd2..71ba95b 100644 --- a/backend/app/services/domain_checker.py +++ b/backend/app/services/domain_checker.py @@ -792,24 +792,28 @@ async def check_all_domains(db): taken = 0 errors = 0 + from app.utils.datetime import to_naive_utc + for domain_obj in domains: try: - check_result = await domain_checker.check_domain(domain_obj.domain) + check_result = await domain_checker.check_domain(domain_obj.name) # Update domain status - domain_obj.status = check_result.status.value + domain_obj.status = check_result.status domain_obj.is_available = check_result.is_available domain_obj.last_checked = datetime.utcnow() + domain_obj.last_check_method = check_result.check_method if check_result.expiration_date: - domain_obj.expiration_date = check_result.expiration_date + domain_obj.expiration_date = to_naive_utc(check_result.expiration_date) # Create check record domain_check = DomainCheck( domain_id=domain_obj.id, - status=check_result.status.value, + status=check_result.status, is_available=check_result.is_available, - check_method=check_result.check_method, + response_data=str(check_result.to_dict()), + checked_at=datetime.utcnow(), ) db.add(domain_check) @@ -819,10 +823,10 @@ async def check_all_domains(db): else: taken += 1 - logger.info(f"Checked {domain_obj.domain}: {check_result.status.value}") + logger.info(f"Checked {domain_obj.name}: {check_result.status.value}") except Exception as e: - logger.error(f"Error checking {domain_obj.domain}: {e}") + logger.error(f"Error checking {domain_obj.name}: {e}") errors += 1 await db.commit() diff --git a/backend/app/services/zone_file.py b/backend/app/services/zone_file.py index eeadde6..a1a9d97 100644 --- a/backend/app/services/zone_file.py +++ b/backend/app/services/zone_file.py @@ -21,6 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.models.zone_file import ZoneSnapshot, DroppedDomain +from app.utils.datetime import to_iso_utc, to_naive_utc logger = logging.getLogger(__name__) @@ -392,17 +393,17 @@ async def get_dropped_domains( "id": item.id, "domain": item.domain, "tld": item.tld, - "dropped_date": item.dropped_date.isoformat(), + "dropped_date": to_iso_utc(item.dropped_date), "length": item.length, "is_numeric": item.is_numeric, "has_hyphen": item.has_hyphen, # Canonical status fields (keep old key for backwards compat) "availability_status": getattr(item, "availability_status", "unknown") or "unknown", "status": getattr(item, "availability_status", "unknown") or "unknown", - "last_status_check": item.last_status_check.isoformat() if getattr(item, "last_status_check", None) else None, - "status_checked_at": item.last_status_check.isoformat() if getattr(item, "last_status_check", None) else None, + "last_status_check": to_iso_utc(item.last_status_check), + "status_checked_at": to_iso_utc(item.last_status_check), "status_source": getattr(item, "last_check_method", None), - "deletion_date": item.deletion_date.isoformat() if getattr(item, "deletion_date", None) else None, + "deletion_date": to_iso_utc(item.deletion_date), } for item in items ] @@ -581,7 +582,7 @@ async def verify_drops_availability( "availability_status": status_result.status, "rdap_status": str(status_result.rdap_status)[:255] if status_result.rdap_status else None, "last_status_check": now, - "deletion_date": status_result.deletion_date, + "deletion_date": to_naive_utc(status_result.deletion_date), "last_check_method": status_result.check_method, } ) diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000..11c076b --- /dev/null +++ b/backend/app/utils/__init__.py @@ -0,0 +1,2 @@ +"""Shared utility helpers (small, dependency-free).""" + diff --git a/backend/app/utils/datetime.py b/backend/app/utils/datetime.py new file mode 100644 index 0000000..bcd956c --- /dev/null +++ b/backend/app/utils/datetime.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from datetime import datetime, timezone + + +def to_naive_utc(dt: datetime | None) -> datetime | None: + """ + Convert a timezone-aware datetime to naive UTC (tzinfo removed). + + Our DB columns are DateTime without timezone. Persisting timezone-aware + datetimes can cause runtime errors (especially on Postgres). + """ + if dt is None: + return None + if dt.tzinfo is None: + return dt + return dt.astimezone(timezone.utc).replace(tzinfo=None) + + +def to_iso_utc(dt: datetime | None) -> str | None: + """ + Serialize a datetime as an ISO-8601 UTC string. + + - If dt is timezone-aware: convert to UTC and use "Z". + - If dt is naive: treat it as UTC and use "Z". + """ + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat().replace("+00:00", "Z") + diff --git a/frontend/src/app/terminal/watchlist/page.tsx b/frontend/src/app/terminal/watchlist/page.tsx index 0835333..3b41eda 100755 --- a/frontend/src/app/terminal/watchlist/page.tsx +++ b/frontend/src/app/terminal/watchlist/page.tsx @@ -42,6 +42,7 @@ import { import clsx from 'clsx' import Link from 'next/link' import Image from 'next/image' +import { daysUntil, formatCountdown } from '@/lib/time' // ============================================================================ // ADD MODAL COMPONENT (like Portfolio) @@ -119,14 +120,6 @@ function AddModal({ // HELPERS // ============================================================================ -function getDaysUntilExpiry(expirationDate: string | null): number | null { - if (!expirationDate) return null - const expDate = new Date(expirationDate) - const now = new Date() - const diffTime = expDate.getTime() - now.getTime() - return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) -} - function formatExpiryDate(expirationDate: string | null): string { if (!expirationDate) return '—' return new Date(expirationDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) @@ -161,7 +154,7 @@ export default function WatchlistPage() { } openAnalyzePanel(domainData.name, { status: statusMap[domainData.status] || (domainData.is_available ? 'available' : 'taken'), - deletion_date: domainData.deletion_date || domainData.expiration_date, + deletion_date: domainData.deletion_date || null, is_drop: false, }) }, [openAnalyzePanel]) @@ -201,7 +194,7 @@ export default function WatchlistPage() { const available = domains?.filter(d => d.is_available) || [] const expiringSoon = domains?.filter(d => { if (d.is_available || !d.expiration_date) return false - const days = getDaysUntilExpiry(d.expiration_date) + const days = daysUntil(d.expiration_date) return days !== null && days <= 30 && days > 0 }) || [] return { total: domains?.length || 0, available: available.length, expiring: expiringSoon.length } @@ -213,7 +206,7 @@ export default function WatchlistPage() { let filtered = domains.filter(d => { if (filter === 'available') return d.is_available if (filter === 'expiring') { - const days = getDaysUntilExpiry(d.expiration_date) + const days = daysUntil(d.expiration_date) return days !== null && days <= 30 && days > 0 } return true @@ -613,13 +606,14 @@ export default function WatchlistPage() { const health = healthReports[domain.id] const healthStatus = health?.status || 'unknown' const config = healthConfig[healthStatus] - const days = getDaysUntilExpiry(domain.expiration_date) + const days = daysUntil(domain.expiration_date) // Domain status display config (consistent with DropsTab) const domainStatus = domain.status || (domain.is_available ? 'available' : 'taken') + const transitionCountdown = domainStatus === 'dropping_soon' ? formatCountdown(domain.deletion_date ?? null) : null const statusConfig = { available: { label: 'AVAIL', color: 'text-accent', bg: 'bg-accent/5 border-accent/20' }, - dropping_soon: { label: 'TRANSITION', color: 'text-amber-400', bg: 'bg-amber-400/5 border-amber-400/20' }, + dropping_soon: { label: transitionCountdown ? `TRANSITION • ${transitionCountdown}` : 'TRANSITION', color: 'text-amber-400', bg: 'bg-amber-400/5 border-amber-400/20' }, taken: { label: 'TAKEN', color: 'text-white/40', bg: 'bg-white/5 border-white/10' }, error: { label: 'ERROR', color: 'text-rose-400', bg: 'bg-rose-400/5 border-rose-400/20' }, unknown: { label: 'CHECK', color: 'text-white/30', bg: 'bg-white/5 border-white/5' }, @@ -642,9 +636,11 @@ export default function WatchlistPage() {
{domain.registrar || 'Unknown'} - {days !== null && days <= 30 && days > 0 && ( + {domainStatus === 'dropping_soon' && transitionCountdown ? ( + drops in {transitionCountdown} + ) : days !== null && days <= 30 && days > 0 ? ( {days}d left - )} + ) : null}
@@ -763,7 +759,9 @@ export default function WatchlistPage() { {/* Expires */}
- {days !== null && days <= 30 && days > 0 ? ( + {domainStatus === 'dropping_soon' && transitionCountdown ? ( + {transitionCountdown} + ) : days !== null && days <= 30 && days > 0 ? ( {days}d ) : ( {formatExpiryDate(domain.expiration_date)} diff --git a/frontend/src/components/analyze/AnalyzePanel.tsx b/frontend/src/components/analyze/AnalyzePanel.tsx index 7375752..a16645e 100644 --- a/frontend/src/components/analyze/AnalyzePanel.tsx +++ b/frontend/src/components/analyze/AnalyzePanel.tsx @@ -22,6 +22,7 @@ import { } from 'lucide-react' import { api } from '@/lib/api' import { useAnalyzePanelStore } from '@/lib/analyze-store' +import { formatCountdown, parseIsoAsUtc } from '@/lib/time' import type { AnalyzeResponse, AnalyzeSection, AnalyzeItem } from '@/components/analyze/types' import { VisionSection } from '@/components/analyze/VisionSection' @@ -278,6 +279,7 @@ export function AnalyzePanel() { }, [data]) const headerDomain = data?.domain || domain || '' + const dropCountdown = useMemo(() => formatCountdown(dropStatus?.deletion_date ?? null), [dropStatus]) if (!isOpen) return null @@ -410,7 +412,9 @@ export function AnalyzePanel() {
{dropStatus.status === 'dropping_soon' && dropStatus.deletion_date && (
- Drops: {new Date(dropStatus.deletion_date).toLocaleDateString()} + {dropCountdown + ? `Drops in ${dropCountdown} • ${parseIsoAsUtc(dropStatus.deletion_date).toLocaleDateString()}` + : `Drops: ${parseIsoAsUtc(dropStatus.deletion_date).toLocaleDateString()}`}
)} diff --git a/frontend/src/components/hunt/DropsTab.tsx b/frontend/src/components/hunt/DropsTab.tsx index cbd101e..fb7a8aa 100644 --- a/frontend/src/components/hunt/DropsTab.tsx +++ b/frontend/src/components/hunt/DropsTab.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { api } from '@/lib/api' import { useAnalyzePanelStore } from '@/lib/analyze-store' +import { formatCountdown } from '@/lib/time' import { Globe, Loader2, @@ -117,7 +118,23 @@ export function DropsTab({ showToast }: DropsTabProps) { // Status Checking const [checkingStatus, setCheckingStatus] = useState(null) const [trackingDrop, setTrackingDrop] = useState(null) - const [trackedDrops, setTrackedDrops] = useState>(new Set()) + const [trackedDomains, setTrackedDomains] = useState>(new Set()) + + // Prefetch Watchlist domains (so Track button shows correct state) + useEffect(() => { + let cancelled = false + const loadTracked = async () => { + try { + const res = await api.getDomains(1, 200) + if (cancelled) return + setTrackedDomains(new Set(res.domains.map(d => d.name.toLowerCase()))) + } catch { + // If unauthenticated, Drops list still renders; "Track" will prompt on action. + } + } + loadTracked() + return () => { cancelled = true } + }, []) // Load Stats const loadStats = useCallback(async () => { @@ -206,29 +223,10 @@ export function DropsTab({ showToast }: DropsTabProps) { } }, [checkingStatus, showToast]) - // Format countdown from deletion date - const formatCountdown = useCallback((deletionDate: string | null): string | null => { - if (!deletionDate) return null - - const del = new Date(deletionDate) - const now = new Date() - const diff = del.getTime() - now.getTime() - - if (diff <= 0) return 'Now' - - const days = Math.floor(diff / (1000 * 60 * 60 * 24)) - const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) - const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)) - - if (days > 0) return `${days}d ${hours}h` - if (hours > 0) return `${hours}h ${mins}m` - return `${mins}m` - }, []) - // Track a drop (add to watchlist) const trackDrop = useCallback(async (dropId: number, domain: string) => { if (trackingDrop) return - if (trackedDrops.has(dropId)) { + if (trackedDomains.has(domain.toLowerCase())) { showToast(`${domain} is already in your Watchlist`, 'info') return } @@ -237,7 +235,11 @@ export function DropsTab({ showToast }: DropsTabProps) { try { const result = await api.trackDrop(dropId) // Mark as tracked regardless of status - setTrackedDrops(prev => new Set(prev).add(dropId)) + setTrackedDomains(prev => { + const next = new Set(prev) + next.add(domain.toLowerCase()) + return next + }) if (result.status === 'already_tracking') { showToast(`${domain} is already in your Watchlist`, 'info') @@ -249,10 +251,10 @@ export function DropsTab({ showToast }: DropsTabProps) { } finally { setTrackingDrop(null) } - }, [trackingDrop, trackedDrops, showToast]) + }, [trackingDrop, trackedDomains, showToast]) - // Check if a drop is already tracked - const isTracked = useCallback((dropId: number) => trackedDrops.has(dropId), [trackedDrops]) + // Check if a drop is already tracked (domain-based, persists across sessions) + const isTracked = useCallback((fullDomain: string) => trackedDomains.has(fullDomain.toLowerCase()), [trackedDomains]) // Filtered and Sorted Items const sortedItems = useMemo(() => { @@ -582,7 +584,7 @@ export function DropsTab({ showToast }: DropsTabProps) { const fullDomain = `${item.domain}.${item.tld}` const isChecking = checkingStatus === item.id const isTrackingThis = trackingDrop === item.id - const alreadyTracked = isTracked(item.id) + const alreadyTracked = isTracked(fullDomain) const status = item.availability_status || 'unknown' // Status display config with better labels diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7bf7ab6..c86bc14 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -486,9 +486,12 @@ class ApiClient { is_available: boolean registrar: string | null expiration_date: string | null + deletion_date?: string | null notify_on_available: boolean created_at: string last_checked: string | null + status_checked_at?: string | null + status_source?: string | null }> total: number page: number diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts index d588f5f..4e130e5 100644 --- a/frontend/src/lib/store.ts +++ b/frontend/src/lib/store.ts @@ -19,9 +19,12 @@ interface Domain { is_available: boolean registrar: string | null expiration_date: string | null + deletion_date?: string | null notify_on_available: boolean created_at: string last_checked: string | null + status_checked_at?: string | null + status_source?: string | null } interface Subscription { diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts new file mode 100644 index 0000000..de55f05 --- /dev/null +++ b/frontend/src/lib/time.ts @@ -0,0 +1,35 @@ +export function parseIsoAsUtc(value: string): Date { + // If the string already contains timezone info, keep it. + // Otherwise treat it as UTC (backend persists naive UTC timestamps). + const hasTimezone = /([zZ]|[+-]\d{2}:\d{2})$/.test(value) + return new Date(hasTimezone ? value : `${value}Z`) +} + +export function formatCountdown(iso: string | null): string | null { + if (!iso) return null + + const target = parseIsoAsUtc(iso) + const now = new Date() + const diff = target.getTime() - now.getTime() + + if (Number.isNaN(diff)) return null + if (diff <= 0) return 'Now' + + const days = Math.floor(diff / (1000 * 60 * 60 * 24)) + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)) + + if (days > 0) return `${days}d ${hours}h` + if (hours > 0) return `${hours}h ${mins}m` + return `${mins}m` +} + +export function daysUntil(iso: string | null): number | null { + if (!iso) return null + const target = parseIsoAsUtc(iso) + const now = new Date() + const diff = target.getTime() - now.getTime() + if (Number.isNaN(diff)) return null + return Math.ceil(diff / (1000 * 60 * 60 * 24)) +} +