fix: normalize transition timestamps across terminal
Some checks failed
Deploy Pounce (Auto) / deploy (push) Has been cancelled

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.
This commit is contained in:
2025-12-21 18:14:25 +01:00
parent 719f4c0724
commit 7c08e90a56
13 changed files with 164 additions and 71 deletions

View File

@ -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

View File

@ -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,
@ -214,6 +215,8 @@ async def api_check_drop_status(
# 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(
update(DroppedDomain)
@ -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()

View File

@ -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

View File

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

View File

@ -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,
}
)

View File

@ -0,0 +1,2 @@
"""Shared utility helpers (small, dependency-free)."""

View File

@ -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")

View File

@ -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() {
</button>
<div className="flex items-center gap-2 mt-2 text-[10px] font-mono text-white/30 uppercase tracking-wider">
<span className="bg-white/5 px-2 py-0.5 border border-white/5">{domain.registrar || 'Unknown'}</span>
{days !== null && days <= 30 && days > 0 && (
{domainStatus === 'dropping_soon' && transitionCountdown ? (
<span className="text-amber-400 font-bold">drops in {transitionCountdown}</span>
) : days !== null && days <= 30 && days > 0 ? (
<span className="text-orange-400 font-bold">{days}d left</span>
)}
) : null}
</div>
</div>
@ -763,7 +759,9 @@ export default function WatchlistPage() {
{/* Expires */}
<div className="text-center text-sm font-mono">
{days !== null && days <= 30 && days > 0 ? (
{domainStatus === 'dropping_soon' && transitionCountdown ? (
<span className="text-amber-400 font-bold">{transitionCountdown}</span>
) : days !== null && days <= 30 && days > 0 ? (
<span className="text-orange-400 font-bold">{days}d</span>
) : (
<span className="text-white/50">{formatExpiryDate(domain.expiration_date)}</span>

View File

@ -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() {
</div>
{dropStatus.status === 'dropping_soon' && dropStatus.deletion_date && (
<div className="text-xs font-mono text-amber-400/70">
Drops: {new Date(dropStatus.deletion_date).toLocaleDateString()}
{dropCountdown
? `Drops in ${dropCountdown}${parseIsoAsUtc(dropStatus.deletion_date).toLocaleDateString()}`
: `Drops: ${parseIsoAsUtc(dropStatus.deletion_date).toLocaleDateString()}`}
</div>
)}
</div>

View File

@ -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<number | null>(null)
const [trackingDrop, setTrackingDrop] = useState<number | null>(null)
const [trackedDrops, setTrackedDrops] = useState<Set<number>>(new Set())
const [trackedDomains, setTrackedDomains] = useState<Set<string>>(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

View File

@ -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

View File

@ -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 {

35
frontend/src/lib/time.ts Normal file
View File

@ -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))
}