Skip to content

Commit 8397dd0

Browse files
committed
fix: add force_refresh param + poisoned property cleanup endpoint
- enrich_property() accepts force_refresh=True to bypass dedup check - SSE stream endpoint accepts ?force_refresh=true query param - POST /api/analysis/admin/cleanup-poisoned deletes properties created during the RENTCAST_API_KEY outage (no beds, no sqft, no type) Protected by X-Internal-Key header. - Standalone cleanup script in scripts/ for future use
1 parent 28fc2ba commit 8397dd0

3 files changed

Lines changed: 205 additions & 2 deletions

File tree

‎backend/core/property_data/service.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ def enrich_property(
150150
db: Session,
151151
providers: list[str] | None = None,
152152
default_strategy: str = "buy_and_hold",
153+
force_refresh: bool = False,
153154
) -> EnrichmentResult:
154155
"""Enrich a property from address string using configured providers.
155156
@@ -188,8 +189,8 @@ def enrich_property(
188189
result.status = "failed"
189190
return result
190191

191-
# 2. Check for existing property (dedup)
192-
existing = _find_existing_property(db, user_id, parsed.address_line1, parsed.zip_code)
192+
# 2. Check for existing property (dedup) — skip if force_refresh
193+
existing = None if force_refresh else _find_existing_property(db, user_id, parsed.address_line1, parsed.zip_code)
193194
if existing:
194195
logger.info("enrich_property: found existing property %s, skipping provider calls", existing.id)
195196
result.property = existing

‎backend/routers/analysis.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,7 @@ async def quick_analysis_stream(
409409
lat: float | None = None,
410410
lng: float | None = None,
411411
place_id: str | None = None,
412+
force_refresh: bool = False,
412413
current_user: User = Depends(get_current_user),
413414
db: Session = Depends(get_db),
414415
_quota: None = Depends(require_quota("analyses_per_month")),
@@ -452,6 +453,7 @@ def _enrich_sync():
452453
address=address, user_id=current_user.id,
453454
db=thread_db, default_strategy=strategy,
454455
providers=["rentcast"],
456+
force_refresh=force_refresh,
455457
)
456458
thread_db.commit()
457459
# Don't pass ORM objects across threads — just IDs.
@@ -466,6 +468,7 @@ def _enrich_sync():
466468
address=address, user_id=current_user.id,
467469
db=db, default_strategy=strategy,
468470
providers=["rentcast"],
471+
force_refresh=force_refresh,
469472
)
470473

471474
enrichment = await asyncio.to_thread(_enrich_sync) if _use_thread else _enrich_sync()
@@ -952,3 +955,84 @@ async def compare_strategies(
952955
recommendation=recommendation,
953956
recommendation_reason=reason,
954957
)
958+
959+
960+
# ---------------------------------------------------------------------------
961+
# POST /api/analysis/admin/cleanup-poisoned — one-time DB cleanup
962+
# ---------------------------------------------------------------------------
963+
964+
@router.post("/admin/cleanup-poisoned")
965+
async def cleanup_poisoned_properties(
966+
request: Request,
967+
db: Session = Depends(get_db),
968+
):
969+
"""Delete properties created during the RENTCAST_API_KEY outage.
970+
971+
Requires X-Internal-Key header. These properties have no enrichment data
972+
and their dedup records prevent re-analysis of those addresses.
973+
"""
974+
import hmac
975+
import os
976+
from sqlalchemy import text
977+
978+
internal_key = os.getenv("INTERNAL_API_KEY", "")
979+
provided_key = request.headers.get("X-Internal-Key", "")
980+
if not internal_key or not hmac.compare_digest(provided_key, internal_key):
981+
raise HTTPException(status_code=401, detail="Unauthorized")
982+
983+
# Find poisoned properties
984+
poisoned = db.execute(text("""
985+
SELECT id, address_line1, city, state, zip_code
986+
FROM properties
987+
WHERE bedrooms IS NULL
988+
AND sqft IS NULL
989+
AND property_type IS NULL
990+
AND is_sample = false
991+
AND is_deleted = false
992+
""")).fetchall()
993+
994+
if not poisoned:
995+
return {"deleted": 0, "message": "No poisoned properties found"}
996+
997+
ids = [row.id for row in poisoned]
998+
999+
# Delete FK-dependent rows
1000+
deleted_counts = {}
1001+
for table in [
1002+
"data_source_events", "analysis_scenarios", "transactions",
1003+
"payments", "obligations", "financing_instruments", "rehab_projects",
1004+
"buyer_packets", "documents", "tasks", "communications",
1005+
"skip_traces", "mail_campaigns", "reports",
1006+
]:
1007+
result = db.execute(
1008+
text(f"DELETE FROM {table} WHERE property_id = ANY(:ids)"),
1009+
{"ids": ids},
1010+
)
1011+
if result.rowcount > 0:
1012+
deleted_counts[table] = result.rowcount
1013+
1014+
# Unlink deals (nullable FK)
1015+
result = db.execute(
1016+
text("UPDATE deals SET property_id = NULL WHERE property_id = ANY(:ids)"),
1017+
{"ids": ids},
1018+
)
1019+
if result.rowcount > 0:
1020+
deleted_counts["deals_unlinked"] = result.rowcount
1021+
1022+
# Delete the properties
1023+
result = db.execute(
1024+
text("DELETE FROM properties WHERE id = ANY(:ids)"),
1025+
{"ids": ids},
1026+
)
1027+
deleted_counts["properties"] = result.rowcount
1028+
1029+
db.commit()
1030+
1031+
addresses = [f"{r.address_line1}, {r.city} {r.state} {r.zip_code}" for r in poisoned]
1032+
logger.info("Cleaned up %d poisoned properties: %s", len(ids), addresses)
1033+
1034+
return {
1035+
"deleted": len(ids),
1036+
"addresses": addresses,
1037+
"details": deleted_counts,
1038+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""One-time cleanup: delete properties created during the RENTCAST_API_KEY outage.
2+
3+
These properties have no enrichment data (no bedrooms, no sqft, no data_sources)
4+
and their scenarios have no purchase_price. Their dedup records prevent re-analysis.
5+
6+
Run via: python3 scripts/cleanup_poisoned_properties.py [DATABASE_URL]
7+
- Inside Railway container: uses DATABASE_URL from env
8+
- Locally: pass the public Postgres URL as an argument
9+
"""
10+
11+
import os
12+
import sys
13+
14+
# Add parent dir so imports work when run from backend/
15+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
16+
17+
try:
18+
from dotenv import load_dotenv
19+
load_dotenv()
20+
except ImportError:
21+
pass
22+
23+
# Allow DATABASE_URL override via CLI argument
24+
if len(sys.argv) > 1:
25+
os.environ["DATABASE_URL"] = sys.argv[1]
26+
27+
from sqlalchemy import create_engine, text
28+
from sqlalchemy.orm import sessionmaker
29+
30+
_url = os.environ.get("DATABASE_URL")
31+
if not _url:
32+
print("ERROR: DATABASE_URL not set. Pass it as an argument or set the env var.")
33+
sys.exit(1)
34+
35+
engine = create_engine(_url)
36+
SessionLocal = sessionmaker(bind=engine)
37+
38+
db = SessionLocal()
39+
40+
try:
41+
# 1. Find poisoned properties: no physical data AND not sample data
42+
poisoned = db.execute(text("""
43+
SELECT p.id, p.address_line1, p.city, p.state, p.zip_code, p.created_at
44+
FROM properties p
45+
WHERE p.bedrooms IS NULL
46+
AND p.sqft IS NULL
47+
AND p.property_type IS NULL
48+
AND p.is_sample = false
49+
AND p.is_deleted = false
50+
""")).fetchall()
51+
52+
if not poisoned:
53+
print("No poisoned properties found. Nothing to clean up.")
54+
sys.exit(0)
55+
56+
print(f"Found {len(poisoned)} poisoned properties:")
57+
for row in poisoned:
58+
print(f" {row.id} — {row.address_line1}, {row.city} {row.state} {row.zip_code} (created {row.created_at})")
59+
60+
ids = [row.id for row in poisoned]
61+
62+
# 2. Delete FK-dependent rows (order matters: children before parents)
63+
fk_tables = [
64+
"data_source_events",
65+
"analysis_scenarios",
66+
"transactions",
67+
"payments",
68+
"obligations",
69+
"financing_instruments",
70+
"rehab_projects",
71+
"buyer_packets",
72+
"documents",
73+
"tasks",
74+
"communications",
75+
"skip_traces",
76+
"mail_campaigns",
77+
"reports",
78+
]
79+
80+
for table in fk_tables:
81+
result = db.execute(
82+
text(f"DELETE FROM {table} WHERE property_id = ANY(:ids)"),
83+
{"ids": ids},
84+
)
85+
if result.rowcount > 0:
86+
print(f" Deleted {result.rowcount} rows from {table}")
87+
88+
# Deals have nullable property_id — null it out instead of deleting the deal
89+
result = db.execute(
90+
text("UPDATE deals SET property_id = NULL WHERE property_id = ANY(:ids)"),
91+
{"ids": ids},
92+
)
93+
if result.rowcount > 0:
94+
print(f" Unlinked {result.rowcount} deals from poisoned properties")
95+
96+
# 3. Delete the poisoned properties
97+
result = db.execute(
98+
text("DELETE FROM properties WHERE id = ANY(:ids)"),
99+
{"ids": ids},
100+
)
101+
print(f" Deleted {result.rowcount} poisoned properties")
102+
103+
db.commit()
104+
105+
# 4. Verify
106+
remaining = db.execute(text("""
107+
SELECT count(*) FROM properties
108+
WHERE bedrooms IS NULL AND sqft IS NULL AND property_type IS NULL
109+
AND is_sample = false AND is_deleted = false
110+
""")).scalar()
111+
print(f"\nVerification: {remaining} poisoned properties remaining (should be 0)")
112+
113+
except Exception as e:
114+
db.rollback()
115+
print(f"ERROR: {e}")
116+
raise
117+
finally:
118+
db.close()

0 commit comments

Comments
 (0)