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() {