From 719f4c07244f8674ad7e261f440db37ecbd8d130 Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Sun, 21 Dec 2025 17:39:47 +0100 Subject: [PATCH] feat: Canonical status metadata across domains and drops --- backend/app/api/check.py | 6 ++++-- backend/app/api/domains.py | 3 +++ backend/app/api/drops.py | 6 +++++- backend/app/db_migrations.py | 9 +++++++++ backend/app/models/domain.py | 13 +++++++++++++ backend/app/models/zone_file.py | 1 + backend/app/scheduler.py | 1 + backend/app/schemas/domain.py | 7 ++++++- backend/app/services/zone_file.py | 12 +++++++++--- 9 files changed, 51 insertions(+), 7 deletions(-) diff --git a/backend/app/api/check.py b/backend/app/api/check.py index a8ae090..7e64b29 100644 --- a/backend/app/api/check.py +++ b/backend/app/api/check.py @@ -30,13 +30,14 @@ async def check_domain_availability(request: DomainCheckRequest): return DomainCheckResponse( domain=result.domain, - status=result.status.value, + status=result.status, is_available=result.is_available, registrar=result.registrar, expiration_date=result.expiration_date, creation_date=result.creation_date, name_servers=result.name_servers, error_message=result.error_message, + status_source=getattr(result, "check_method", None), checked_at=datetime.utcnow(), ) @@ -61,13 +62,14 @@ async def check_domain_get(domain: str, quick: bool = False): return DomainCheckResponse( domain=result.domain, - status=result.status.value, + status=result.status, is_available=result.is_available, registrar=result.registrar, expiration_date=result.expiration_date, creation_date=result.creation_date, name_servers=result.name_servers, error_message=result.error_message, + status_source=getattr(result, "check_method", None), checked_at=datetime.utcnow(), ) diff --git a/backend/app/api/domains.py b/backend/app/api/domains.py index 969c33e..c8c9fb7 100644 --- a/backend/app/api/domains.py +++ b/backend/app/api/domains.py @@ -175,6 +175,7 @@ async def add_domain( expiration_date=check_result.expiration_date, notify_on_available=domain_data.notify_on_available, last_checked=datetime.utcnow(), + last_check_method=check_result.check_method, ) db.add(domain) await db.flush() @@ -277,6 +278,7 @@ async def refresh_domain( domain.registrar = check_result.registrar domain.expiration_date = _to_naive_utc(check_result.expiration_date) domain.last_checked = datetime.utcnow() + domain.last_check_method = check_result.check_method # Create check record check = DomainCheck( @@ -354,6 +356,7 @@ async def refresh_all_domains( domain.registrar = check_result.registrar domain.expiration_date = _to_naive_utc(check_result.expiration_date) domain.last_checked = datetime.utcnow() + domain.last_check_method = check_result.check_method # Create check record check = DomainCheck( diff --git a/backend/app/api/drops.py b/backend/app/api/drops.py index 2839985..c56f2f6 100644 --- a/backend/app/api/drops.py +++ b/backend/app/api/drops.py @@ -221,7 +221,9 @@ async def api_check_drop_status( .values( availability_status=status_result.status, rdap_status=str(status_result.rdap_status) if status_result.rdap_status else None, - last_status_check=datetime.utcnow() + last_status_check=datetime.utcnow(), + deletion_date=status_result.deletion_date, + last_check_method=status_result.check_method, ) ) await db.commit() @@ -235,6 +237,8 @@ async def api_check_drop_status( "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(), + "status_source": status_result.check_method, } except Exception as e: diff --git a/backend/app/db_migrations.py b/backend/app/db_migrations.py index ccdf82f..05921a5 100644 --- a/backend/app/db_migrations.py +++ b/backend/app/db_migrations.py @@ -109,6 +109,11 @@ async def apply_migrations(conn: AsyncConnection) -> None: # 2b) domains indexes (watchlist list/sort/filter) # --------------------------------------------------------- if await _table_exists(conn, "domains"): + # 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)")) + 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)")) await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_domains_user_created_at ON domains(user_id, created_at)")) @@ -130,6 +135,10 @@ async def apply_migrations(conn: AsyncConnection) -> None: # 2d) dropped_domains indexes + de-duplication # --------------------------------------------------------- if await _table_exists(conn, "dropped_domains"): + 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)")) + # Query patterns: # - by time window (dropped_date) + optional tld + keyword # - status updates (availability_status + last_status_check) diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 3872859..1810df0 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -42,6 +42,8 @@ class Domain(Base): # Timestamps created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + # How the current status was derived (rdap_iana, whois, dns, etc.) + last_check_method: Mapped[str | None] = mapped_column(String(30), nullable=True) # Check history relationship checks: Mapped[list["DomainCheck"]] = relationship( @@ -54,6 +56,17 @@ class Domain(Base): def __repr__(self) -> str: return f"" + # ------------------------------------------------------------------ + # Canonical status fields (API stability for Terminal consistency) + # ------------------------------------------------------------------ + @property + def status_checked_at(self) -> datetime | None: + return self.last_checked + + @property + def status_source(self) -> str | None: + return self.last_check_method + class DomainCheck(Base): """History of domain availability checks.""" diff --git a/backend/app/models/zone_file.py b/backend/app/models/zone_file.py index 35ecb94..96e2451 100644 --- a/backend/app/models/zone_file.py +++ b/backend/app/models/zone_file.py @@ -43,6 +43,7 @@ class DroppedDomain(Base): rdap_status = Column(String(255), nullable=True) # Raw RDAP status string last_status_check = Column(DateTime, nullable=True) deletion_date = Column(DateTime, nullable=True) # When domain will be fully deleted + last_check_method = Column(String(30), nullable=True) # rdap_iana, rdap_ch, error, etc. __table_args__ = ( Index('ix_dropped_domains_tld_date', 'tld', 'dropped_date'), diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 5dfa5d7..970a678 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -170,6 +170,7 @@ async def check_domains_by_frequency(frequency: str): domain.registrar = check_result.registrar domain.expiration_date = check_result.expiration_date domain.last_checked = datetime.utcnow() + domain.last_check_method = getattr(check_result, "check_method", None) # Create check record for history check = DomainCheck( diff --git a/backend/app/schemas/domain.py b/backend/app/schemas/domain.py index 4955814..b012196 100644 --- a/backend/app/schemas/domain.py +++ b/backend/app/schemas/domain.py @@ -39,9 +39,13 @@ class DomainResponse(BaseModel): is_available: bool registrar: Optional[str] expiration_date: Optional[datetime] + deletion_date: Optional[datetime] = None notify_on_available: bool created_at: datetime last_checked: Optional[datetime] + # Canonical status metadata (stable across Terminal modules) + status_checked_at: Optional[datetime] = None + status_source: Optional[str] = None class Config: from_attributes = True @@ -70,13 +74,14 @@ class DomainCheckRequest(BaseModel): class DomainCheckResponse(BaseModel): """Schema for domain check response.""" domain: str - status: str + status: DomainStatus is_available: bool registrar: Optional[str] = None expiration_date: Optional[datetime] = None creation_date: Optional[datetime] = None name_servers: Optional[List[str]] = None error_message: Optional[str] = None + status_source: Optional[str] = None checked_at: datetime diff --git a/backend/app/services/zone_file.py b/backend/app/services/zone_file.py index da51edb..eeadde6 100644 --- a/backend/app/services/zone_file.py +++ b/backend/app/services/zone_file.py @@ -396,9 +396,13 @@ async def get_dropped_domains( "length": item.length, "is_numeric": item.is_numeric, "has_hyphen": item.has_hyphen, - "availability_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, - "deletion_date": item.deletion_date.isoformat() if getattr(item, 'deletion_date', None) else None, + # 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, + "status_source": getattr(item, "last_check_method", None), + "deletion_date": item.deletion_date.isoformat() if getattr(item, "deletion_date", None) else None, } for item in items ] @@ -578,6 +582,7 @@ async def verify_drops_availability( "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, + "last_check_method": status_result.check_method, } ) @@ -590,6 +595,7 @@ async def verify_drops_availability( rdap_status=bindparam("rdap_status"), last_status_check=bindparam("last_status_check"), deletion_date=bindparam("deletion_date"), + last_check_method=bindparam("last_check_method"), ) ) await db.execute(stmt, updates)