rmi-backend/app/routers/supabase_router.py
opencode c762564d40 style(rmi-backend): complete lint cleanup — 1175→0 ruff errors
- Fix 71 invalid-syntax files (class-body newline-broken assignments)
- Add from/None chain to 307 B904 raise-without-from sites
- Add B008 ignore to ruff.toml (already in pyproject.toml)
- Noqa F401 on __init__.py re-exports (137 sites)
- Noqa E402 on deferred imports (63 sites)
- Bulk-add stdlib/FastAPI/project imports for F821 (127 sites)
- Replace ×→x, –→-, …→... in docstrings (4093 chars)
- Manual refactor of 5 SIM103/SIM116 patterns

Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py)
Co-authored-by: opencode <opencode@rugmunch.io>
2026-07-06 15:43:20 +02:00

539 lines
19 KiB
Python

#!/usr/bin/env python3
"""
RMI Supabase API Router - Exposes all platform tables via REST endpoints.
Frontend connects here for live, real-time data from Supabase.
"""
import contextlib
from datetime import UTC, datetime
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from app.services.supabase_service import (
acknowledge_alert,
add_to_watchlist,
check_health,
create_alert,
get_alerts,
get_cluster_wallets,
get_entity_clusters,
get_market_data,
get_subscription,
get_syndicate_wallets,
get_token,
get_tokens,
get_transactions,
get_wallet,
get_wallet_connections,
get_wallet_intel,
get_wallets,
get_watchlist,
remove_from_watchlist,
store_market_data,
track_event,
upsert_wallet,
)
router = APIRouter(prefix="/api/v1")
# ── Models ────────────────────────────────────────────────────
class WalletUpsert(BaseModel):
address: str
chain: str = "ethereum"
label: str | None = None
risk_score: int | None = None
tags: list[str] | None = None
balance_usd: float | None = None
metadata: dict | None = None
class WatchlistItem(BaseModel):
user_id: str
address: str
type: str = "wallet" # "token" or "wallet"
chain: str = "ethereum"
tags: list[str] | None = None
class MarketDataIn(BaseModel):
source: str
data_type: str
data: dict
# ═════════════════════════════════════════════════════════
# ALERTS
# ═════════════════════════════════════════════════════════
@router.get("/alerts")
async def alerts(
severity: str = Query(None),
user_id: str = Query(None),
limit: int = Query(50, ge=1, le=500),
unread: bool = Query(False),
):
data = await get_alerts(severity=severity, user_id=user_id, limit=limit, unread_only=unread)
return {"alerts": data, "total": len(data)}
@router.post("/alerts")
async def create_alert_endpoint(
title: str,
description: str,
severity: str = "medium",
alert_type: str = "security",
chain: str = Query(None),
token: str = Query(None),
wallet: str = Query(None),
amount_usd: float = Query(None),
user_id: str = Query(None),
):
result = await create_alert(
title=title,
description=description,
severity=severity,
alert_type=alert_type,
chain=chain,
token=token,
wallet=wallet,
amount_usd=amount_usd,
user_id=user_id,
)
if not result:
raise HTTPException(status_code=500, detail="Create failed")
return result
@router.post("/alerts/{alert_id}/ack")
async def ack_alert(alert_id: str):
ok = await acknowledge_alert(alert_id)
if not ok:
raise HTTPException(status_code=404, detail="Alert not found")
return {"status": "acknowledged"}
# ═════════════════════════════════════════════════════════
# WALLETS
# ═════════════════════════════════════════════════════════
@router.get("/wallets")
async def wallets(
chain: str = Query(None),
risk_min: int = Query(None),
risk_max: int = Query(None),
limit: int = Query(50, ge=1, le=500),
):
data = await get_wallets(chain=chain, risk_min=risk_min, risk_max=risk_max, limit=limit)
return {"wallets": data, "total": len(data)}
@router.get("/wallets/{address}")
async def wallet_detail(address: str, chain: str = Query("ethereum")):
wallet = await get_wallet(address, chain)
if not wallet:
raise HTTPException(status_code=404, detail="Wallet not found")
# Enrich with intel and transactions
intel = await get_wallet_intel(address=address, chain=chain, limit=1)
txns = await get_transactions(wallet_address=address, chain=chain, limit=20)
return {"wallet": wallet, "intel": intel[0] if intel else None, "transactions": txns}
@router.post("/wallets")
async def wallet_upsert(w: WalletUpsert):
result = await upsert_wallet(
w.address,
w.chain,
label=w.label,
risk_score=w.risk_score,
tags=w.tags,
balance_usd=w.balance_usd,
metadata=w.metadata,
)
if not result:
raise HTTPException(status_code=500, detail="Upsert failed")
return result
@router.get("/wallets/{address}/connections")
async def wallet_connections(address: str):
connections = await get_wallet_connections(address)
return {"address": address, "connections": connections, "count": len(connections)}
# ═════════════════════════════════════════════════════════
# SYNDICATE WALLETS
# ═════════════════════════════════════════════════════════
@router.get("/syndicate")
async def syndicates(status: str = Query(None), chain: str = Query(None), limit: int = Query(200, ge=1, le=500)):
data = await get_syndicate_wallets(status=status, chain=chain, limit=limit)
return {"syndicate_wallets": data, "total": len(data)}
# ═════════════════════════════════════════════════════════
# WATCHLIST (supports tokens and wallets)
# ═════════════════════════════════════════════════════════
@router.get("/watchlist/{user_id}")
async def watchlist(user_id: str, type: str | None = Query(None)):
"""Get user's watchlist. Optional ?type=token|wallet filter.
Tries Supabase first, falls back to Redis."""
data = []
# Try Supabase first
with contextlib.suppress(Exception):
data = await get_watchlist(user_id)
# Redis fallback
if not data:
import json as _json
import os
try:
import redis as _redis
_r = _redis.Redis(
host=os.getenv("REDIS_HOST", "rmi-redis"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD") or None,
decode_responses=True,
)
wl_key = f"rmi:watchlist:{user_id}"
raw_entries = _r.lrange(wl_key, 0, -1)
data = [_json.loads(e) for e in raw_entries]
except Exception:
pass
if type:
data = [item for item in data if item.get("type") == type]
return {"watchlist": data, "total": len(data)}
@router.post("/watchlist")
async def watchlist_add(item: WatchlistItem):
"""Add a token or wallet address to the user's watchlist.
Tries Supabase first, falls back to Redis."""
if item.type not in ("token", "wallet"):
raise HTTPException(status_code=400, detail="type must be 'token' or 'wallet'")
result = None
# Try Supabase first
with contextlib.suppress(Exception):
result = await add_to_watchlist(
user_id=item.user_id,
address=item.address,
chain=item.chain,
tags=item.tags,
item_type=item.type,
)
# Redis fallback - always written so watchlist works even without Supabase
if not result:
import json as _json
import os
try:
import redis as _redis
_r = _redis.Redis(
host=os.getenv("REDIS_HOST", "rmi-redis"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD") or None,
decode_responses=True,
)
wl_key = f"rmi:watchlist:{item.user_id}"
entry = _json.dumps(
{
"user_id": item.user_id,
"address": item.address,
"type": item.type,
"chain": item.chain,
"tags": item.tags or [],
"created_at": datetime.now(UTC).isoformat(),
}
)
_r.lpush(wl_key, entry)
_r.expire(wl_key, 86400 * 30) # 30 day TTL
result = _json.loads(entry)
except Exception:
pass
if not result:
raise HTTPException(status_code=500, detail="Add failed - both Supabase and Redis unavailable")
# Broadcast alert via WebSocket so frontend gets notified
try:
import asyncio
from main import ws_broadcast_alert
asyncio.create_task(
ws_broadcast_alert(
{
"event": "watchlist_add",
"user_id": item.user_id,
"address": item.address,
"type": item.type,
"chain": item.chain,
}
)
)
except Exception:
pass
return result
@router.delete("/watchlist/{user_id}/{address}")
async def watchlist_remove(user_id: str, address: str, chain: str = Query("ethereum")):
"""Remove item from watchlist. Tries Supabase and Redis."""
ok = False
with contextlib.suppress(Exception):
ok = await remove_from_watchlist(user_id, address, chain)
# Also remove from Redis
import json as _json
import os
try:
import redis as _redis
_r = _redis.Redis(
host=os.getenv("REDIS_HOST", "rmi-redis"),
port=int(os.getenv("REDIS_PORT", "6379")),
password=os.getenv("REDIS_PASSWORD") or None,
decode_responses=True,
)
wl_key = f"rmi:watchlist:{user_id}"
raw_entries = _r.lrange(wl_key, 0, -1)
for raw in raw_entries:
entry = _json.loads(raw)
if entry.get("address") == address and entry.get("chain", "ethereum") == chain:
_r.lrem(wl_key, 1, raw)
ok = True
break
except Exception:
pass
if not ok:
raise HTTPException(status_code=404, detail="Not found")
return {"status": "removed"}
# ═════════════════════════════════════════════════════════
# TOKENS
# ═════════════════════════════════════════════════════════
@router.get("/tokens")
async def tokens(
chain: str = Query(None),
limit: int = Query(50, ge=1, le=200),
order: str = Query("volume_24h.desc"),
):
data = await get_tokens(chain=chain, limit=limit, order_by=order)
return {"tokens": data, "total": len(data)}
@router.get("/tokens/discover")
async def token_discover(chains: str = Query("solana,ethereum,base,bsc")):
"""Discover new token launches across chains via DexScreener + GeckoTerminal."""
chain_list = [c.strip() for c in chains.split(",") if c.strip()]
from app.token_discovery import discover_tokens
try:
discovered = await discover_tokens(chains=chain_list)
total = sum(len(tokens) for tokens in discovered.values())
return {"chains": discovered, "total": total}
except Exception as e:
return {"error": str(e), "chains": {}, "total": 0}
@router.get("/tokens/trending")
async def trending_tokens(limit: int = Query(20, ge=1, le=50)):
"""Trending tokens from CoinGecko with GeckoTerminal fallback."""
from app.coingecko_connector import CoinGeckoConnector
tokens = []
try:
cg = CoinGeckoConnector()
data = await cg.get_trending() # returns [{id, symbol, name, market_cap_rank, price_btc, score}]
if data and isinstance(data, list):
for item in data[:limit]:
tokens.append(
{
"address": item.get("id", ""),
"name": item.get("name", ""),
"symbol": item.get("symbol", "").upper(),
"price_usd": None,
"price_btc": item.get("price_btc"),
"volume_h24": None,
"change_24h": None,
"market_cap_rank": item.get("market_cap_rank"),
"flags": [],
}
)
except Exception:
pass
# Fallback: GeckoTerminal trending pools
if not tokens:
import httpx
try:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(
"https://api.geckoterminal.com/api/v2/networks/solana/trending_pools",
headers={"Accept": "application/json"},
)
if r.status_code == 200:
ds = r.json()
for pool in (ds.get("data", []) or [])[:limit]:
attrs = pool.get("attributes", {})
rel = pool.get("relationships", {})
base_attrs = {}
with contextlib.suppress(Exception):
base_attrs = rel.get("base_token", {}).get("data", {}) or {}
tokens.append(
{
"address": base_attrs.get("address", pool.get("id", "")[:12]),
"name": attrs.get("name", pool.get("id", "")[:12]),
"symbol": (attrs.get("symbol", "") or base_attrs.get("symbol", "") or "").upper(),
"price_usd": float(attrs.get("base_token_price_usd", 0) or 0),
"volume_h24": str(
attrs.get("volume_usd", {}).get("h24", 0)
if isinstance(attrs.get("volume_usd"), dict)
else 0
),
"change_24h": float(
attrs.get("price_change_percentage", {}).get("h24", 0)
if isinstance(attrs.get("price_change_percentage"), dict)
else 0
),
"flags": ["solana"],
}
)
except Exception:
pass
return {"tokens": tokens, "count": len(tokens)}
@router.get("/tokens/{address}")
async def token_detail(address: str, chain: str = Query("ethereum")):
token = await get_token(address, chain)
if not token:
raise HTTPException(status_code=404, detail="Token not found")
return token
# ═════════════════════════════════════════════════════════
# ENTITY CLUSTERS
# ═════════════════════════════════════════════════════════
@router.get("/clusters")
async def clusters(limit: int = Query(50, ge=1, le=200)):
data = await get_entity_clusters(limit=limit)
return {"clusters": data, "total": len(data)}
@router.get("/clusters/{cluster_id}")
async def cluster_detail(cluster_id: str):
addresses = await get_cluster_wallets(cluster_id)
return {"cluster_id": cluster_id, "addresses": addresses, "count": len(addresses)}
# ═════════════════════════════════════════════════════════
# TRANSACTIONS
# ═════════════════════════════════════════════════════════
@router.get("/transactions/{wallet_address}")
async def transactions(wallet_address: str, chain: str = Query(None), limit: int = Query(50)):
data = await get_transactions(wallet_address=wallet_address, chain=chain, limit=limit)
return {"transactions": data, "total": len(data)}
# ═════════════════════════════════════════════════════════
# MARKET DATA
# ═════════════════════════════════════════════════════════
@router.get("/market-data/{data_type}")
async def market_data(data_type: str, limit: int = Query(10)):
data = await get_market_data(data_type=data_type, limit=limit)
return {"data": data, "total": len(data)}
@router.post("/market-data")
async def market_data_store(m: MarketDataIn):
result = await store_market_data(m.source, m.data_type, m.data)
if not result:
raise HTTPException(status_code=500, detail="Store failed")
return result
# ═════════════════════════════════════════════════════════
# SUBSCRIPTIONS
# ═════════════════════════════════════════════════════════
@router.get("/subscriptions/{user_id}")
async def subscription(user_id: str):
sub = await get_subscription(user_id)
if not sub:
return {"plan": "FREE", "status": "none"}
return sub
# ═════════════════════════════════════════════════════════
# ANALYTICS
# ═════════════════════════════════════════════════════════
@router.post("/analytics/event")
async def analytics_event(
event_type: str,
user_id: str = Query(None),
event_data: str = Query("{}"),
page: str = Query(None),
):
import json
try:
data = json.loads(event_data)
except Exception:
data = {"raw": event_data}
result = await track_event(event_type=event_type, user_id=user_id, event_data=data, page=page)
return {"status": "tracked", "id": result.get("id") if result else None}
# ═════════════════════════════════════════════════════════
# HEALTH
# ═════════════════════════════════════════════════════════
@router.get("/health/supabase")
async def supabase_health():
return await check_health()
@router.get("/health")
async def full_health():
sb = await check_health()
tables = [
"wallets",
"alerts",
"watchlist",
"tokens",
"bulletin_posts",
"badges",
"syndicate_wallets",
"entity_clusters",
]
return {"supabase": sb, "timestamp": datetime.now().isoformat(), "tables_monitored": tables}