Some checks failed
CI / build (push) Failing after 3s
PHASE 2.3 (AUDIT-2026-Q3.md):
Task 1 — Wire-in Wave 3 (1 router mounted, 2 deferred):
- app.routers.unified_scanner_router mounted at /api/v2/scanner/* (2 routes:
POST /api/v2/scanner/token/scan, POST /api/v2/scanner/wallet/scan).
Refactored prefix from /api/v2 -> /api/v2/scanner to avoid future conflicts
with the v1 /api/v1/scanner/ stub.
- app.routers.unified_wallet_scanner DEFERRED (no router APIRouter attribute;
library module consumed by unified_scanner_router via get_wallet_scanner()).
- app.routers.admin_extensions DEFERRED (DORMANT per audit; 25 routes at
/api/v1/admin/* would shadow /api/v1/admin/alerts_webhook).
Task 2 — Archive 136 dead-code files to app/_archive/legacy_2026_07/:
- 73 routers in app/routers/ (reach graph showed zero reach into mount.py).
- 63 flat app/*.py (domain modules never imported by live code).
- 1 file RESTORED post-archive: app/routers/x402_bridge_health.py (caught by
tests/unit/test_bridge_health.py which directly imports it; reach graph
considered tests/ only as transitive reach — to be patched in next cycle).
Forced-LIVE (NOT archived per user directive):
- app/ai_pipeline_v3.py (3 importers in audit window, importers themselves DEAD)
- app/splade_bm25.py (LIVE via app.rag_service)
- app/wallet_manager_v2.py (LIVE via x402_enforcement, x402_tools, sweep_all, sweep_now)
- app/crypto_embeddings.py (NOT in audit ARCHIVE list; heavy import graph)
Verification (forward-import closure from mount.py + main.py + factory.py + lifespan.py):
- imports = 348 app.* modules
- reached = 194 files reachable from roots
- archive set = audit_dead (186) - reached - forced_live (4) - test_live (1) = 136
- Net delta: 136 files moved, 44,932 LOC reduction, 293->295 active routes (+2 from Wave 3)
pyproject.toml updates:
- setuptools.packages.find: added exclude for app._archive*
- ruff.extend-exclude: added "app/_archive/"
- mypy.exclude: added "app/_archive/"
Smoke test: pytest tests/ — 817 passed, 3 pre-existing failures unchanged
(0 new failures; 0 routes lost; all 4 forced-LIVE files still importable).
Restoration: git mv app/_archive/legacy_2026_07/<name>.py <original-path>
and add the import to app/mount.py ROUTER_MODULES.
Refs: AUDIT-2026-Q3.md /home/dev/pry/rmi-final-deadcode-2026-07-06.md
841 lines
32 KiB
Python
841 lines
32 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
RMI Supabase Database Client
|
|
============================
|
|
Persistent PostgreSQL storage via Supabase with Redis caching.
|
|
Write-through: Supabase first, then Redis.
|
|
Read-through: Redis first, Supabase on miss.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
from collections.abc import Callable
|
|
from datetime import UTC, datetime
|
|
from functools import wraps
|
|
from typing import Any
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
# Load .env with override to ensure JWT keys win over stale Docker env vars
|
|
load_dotenv("/app/.env", override=True)
|
|
|
|
from pydantic import BaseModel, Field # noqa: E402
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── Lazy imports to avoid import-time failures ──
|
|
try:
|
|
from supabase import Client, create_client
|
|
except ImportError as _imp_err:
|
|
create_client = None
|
|
Client = None
|
|
logger.warning(f"[DB] supabase client not installed: {_imp_err}")
|
|
|
|
try:
|
|
import redis.asyncio as redis
|
|
except ImportError:
|
|
redis = None
|
|
|
|
# ── SQL to ensure missing tables exist ──
|
|
# Run these in the Supabase SQL Editor if the tables are missing.
|
|
ENSURE_TABLES_SQL = """
|
|
-- Users / Profiles (extends auth.users for wallet-auth users)
|
|
CREATE TABLE IF NOT EXISTS profiles (
|
|
id TEXT PRIMARY KEY,
|
|
email TEXT,
|
|
wallet_address TEXT UNIQUE,
|
|
role TEXT DEFAULT 'USER',
|
|
tier TEXT DEFAULT 'FREE',
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
xp INTEGER DEFAULT 0,
|
|
level INTEGER DEFAULT 1,
|
|
display_name TEXT,
|
|
scans_remaining INTEGER DEFAULT 5,
|
|
total_scans_used INTEGER DEFAULT 0,
|
|
reputation_score INTEGER DEFAULT 0
|
|
);
|
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Profiles public read" ON profiles FOR SELECT USING (true);
|
|
CREATE POLICY "Profiles admin write" ON profiles FOR ALL USING (
|
|
EXISTS (SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin')
|
|
);
|
|
|
|
-- Contract Audits
|
|
CREATE TABLE IF NOT EXISTS contract_audits (
|
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
|
address TEXT NOT NULL,
|
|
chain TEXT DEFAULT 'solana',
|
|
risk_score NUMERIC DEFAULT 0,
|
|
findings JSONB DEFAULT '[]',
|
|
ai_analysis JSONB DEFAULT '{}',
|
|
audited_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
ALTER TABLE contract_audits ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Audits public read" ON contract_audits FOR SELECT USING (true);
|
|
CREATE POLICY "Audits admin write" ON contract_audits FOR ALL USING (
|
|
EXISTS (SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin')
|
|
);
|
|
|
|
-- Trenches Posts (community board)
|
|
CREATE TABLE IF NOT EXISTS trenches_posts (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
category TEXT DEFAULT 'discussion',
|
|
author_id TEXT,
|
|
upvotes INTEGER DEFAULT 0,
|
|
comments INTEGER DEFAULT 0,
|
|
tags TEXT[] DEFAULT '{}',
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
ALTER TABLE trenches_posts ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Posts public read" ON trenches_posts FOR SELECT USING (true);
|
|
CREATE POLICY "Posts owner write" ON trenches_posts FOR ALL USING (
|
|
auth.uid()::text = author_id OR
|
|
EXISTS (SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin')
|
|
);
|
|
|
|
-- Gamification Profiles
|
|
CREATE TABLE IF NOT EXISTS gamification_profiles (
|
|
user_id TEXT PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
|
xp INTEGER DEFAULT 0,
|
|
level INTEGER DEFAULT 1,
|
|
badges TEXT[] DEFAULT '{}',
|
|
scans_count INTEGER DEFAULT 0,
|
|
posts_count INTEGER DEFAULT 0,
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
ALTER TABLE gamification_profiles ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY "Gamification public read" ON gamification_profiles FOR SELECT USING (true);
|
|
CREATE POLICY "Gamification owner write" ON gamification_profiles FOR ALL USING (
|
|
auth.uid()::text = user_id OR
|
|
EXISTS (SELECT 1 FROM auth.users WHERE auth.users.id = auth.uid() AND auth.users.role = 'admin')
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX IF NOT EXISTS idx_profiles_wallet ON profiles(wallet_address);
|
|
CREATE INDEX IF NOT EXISTS idx_contract_audits_address ON contract_audits(address);
|
|
CREATE INDEX IF NOT EXISTS idx_trenches_posts_category ON trenches_posts(category);
|
|
CREATE INDEX IF NOT EXISTS idx_trenches_posts_created ON trenches_posts(created_at DESC);
|
|
"""
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# RETRY UTILITIES
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
def _async_retry(max_retries: int = 3, delay: float = 0.5, backoff: float = 2.0):
|
|
"""Simple async retry decorator for Supabase operations."""
|
|
|
|
def decorator(func: Callable):
|
|
@wraps(func)
|
|
async def wrapper(*args, **kwargs):
|
|
last_exc = None
|
|
wait = delay
|
|
for attempt in range(max_retries + 1):
|
|
try:
|
|
return await func(*args, **kwargs)
|
|
except Exception as exc:
|
|
last_exc = exc
|
|
if attempt >= max_retries:
|
|
raise
|
|
logger.warning(f"[DB] Retry {attempt + 1}/{max_retries} for {func.__name__}: {exc}")
|
|
await asyncio.sleep(wait)
|
|
wait *= backoff
|
|
raise last_exc
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# PYDANTIC MODELS
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
class User(BaseModel):
|
|
id: str
|
|
email: str | None = None
|
|
wallet_address: str | None = None
|
|
role: str = "USER"
|
|
tier: str = "FREE"
|
|
created_at: str | None = None
|
|
xp: int = 0
|
|
level: int = 1
|
|
|
|
|
|
class InvestigationCase(BaseModel):
|
|
id: str
|
|
target: str
|
|
type: str = "wallet"
|
|
status: str = "open"
|
|
evidence: list[str] = Field(default_factory=list)
|
|
agents_assigned: list[str] = Field(default_factory=list)
|
|
created_at: str | None = None
|
|
updated_at: str | None = None
|
|
risk_score: float = 0.0
|
|
findings: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class WalletScan(BaseModel):
|
|
id: str | None = None
|
|
address: str
|
|
chain: str = "solana"
|
|
risk_score: float = 0.0
|
|
findings: dict[str, Any] = Field(default_factory=dict)
|
|
scanned_at: str | None = None
|
|
user_id: str | None = None
|
|
|
|
|
|
class ContractAudit(BaseModel):
|
|
id: str | None = None
|
|
address: str
|
|
chain: str = "solana"
|
|
risk_score: float = 0.0
|
|
findings: list[dict[str, Any]] = Field(default_factory=list)
|
|
ai_analysis: dict[str, Any] = Field(default_factory=dict)
|
|
audited_at: str | None = None
|
|
|
|
|
|
class TrenchesPost(BaseModel):
|
|
id: str | None = None
|
|
title: str
|
|
content: str
|
|
category: str = "discussion"
|
|
author_id: str | None = None
|
|
upvotes: int = 0
|
|
comments: int = 0
|
|
tags: list[str] = Field(default_factory=list)
|
|
created_at: str | None = None
|
|
|
|
|
|
class Alert(BaseModel):
|
|
id: str | None = None
|
|
token_address: str
|
|
types: list[str] = Field(default_factory=list)
|
|
webhook_url: str | None = None
|
|
active: bool = True
|
|
created_at: str | None = None
|
|
|
|
|
|
class GamificationProfile(BaseModel):
|
|
user_id: str
|
|
xp: int = 0
|
|
level: int = 1
|
|
badges: list[str] = Field(default_factory=list)
|
|
scans_count: int = 0
|
|
posts_count: int = 0
|
|
updated_at: str | None = None
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# SUPABASE CLIENT
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
class SupabaseClient:
|
|
"""Wrapped Supabase client with connection pooling, retries, and helpers."""
|
|
|
|
def __init__(self, url: str | None = None, key: str | None = None):
|
|
self.url = url or os.getenv("SUPABASE_URL", "")
|
|
# Fall back through env var variants
|
|
self.key = (
|
|
key or os.getenv("SUPABASE_SERVICE_KEY") or os.getenv("SUPABASE_KEY") or os.getenv("SUPABASE_ANON_KEY", "")
|
|
)
|
|
self._client: Any | None = None
|
|
|
|
def _ensure_client(self) -> Any:
|
|
if self._client is None:
|
|
if create_client is None:
|
|
raise RuntimeError("supabase package is not installed")
|
|
if not self.url or not self.key:
|
|
raise RuntimeError("SUPABASE_URL and SUPABASE_KEY must be set")
|
|
self._client = create_client(self.url, self.key)
|
|
return self._client
|
|
|
|
@property
|
|
def client(self) -> Any:
|
|
return self._ensure_client()
|
|
|
|
# ── Auth helpers ──
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def sign_up(self, email: str, password: str) -> dict[str, Any]:
|
|
"""Register a new user via Supabase Auth."""
|
|
result = self.client.auth.sign_up({"email": email, "password": password})
|
|
return result.model_dump() if hasattr(result, "model_dump") else dict(result)
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def sign_in(self, email: str, password: str) -> dict[str, Any]:
|
|
"""Sign in via Supabase Auth."""
|
|
result = self.client.auth.sign_in_with_password({"email": email, "password": password})
|
|
return result.model_dump() if hasattr(result, "model_dump") else dict(result)
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def get_user(self, token: str) -> dict[str, Any] | None:
|
|
"""Get user by JWT token."""
|
|
result = self.client.auth.get_user(token)
|
|
if result and result.user:
|
|
return {
|
|
"id": result.user.id,
|
|
"email": result.user.email,
|
|
}
|
|
return None
|
|
|
|
# ── Table access ──
|
|
|
|
def table(self, name: str) -> Any:
|
|
return self.client.table(name)
|
|
|
|
# ── SQL execution (requires service role or elevated privileges) ──
|
|
|
|
@_async_retry(max_retries=2)
|
|
async def execute_sql(self, sql: str) -> Any:
|
|
"""Execute raw SQL via RPC (requires a stored procedure or service role)."""
|
|
# Prefer using migrations; this is a fallback.
|
|
return self.client.rpc("exec_sql", {"query": sql}).execute()
|
|
|
|
@_async_retry(max_retries=2)
|
|
async def ensure_tables(self) -> None:
|
|
"""Best-effort table creation. Requires exec_sql RPC or run SQL manually."""
|
|
try:
|
|
await self.execute_sql(ENSURE_TABLES_SQL)
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] ensure_tables failed (tables may already exist or RPC missing): {exc}")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# REPOSITORIES
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
class _BaseRepo:
|
|
def __init__(self, db: SupabaseClient):
|
|
self.db = db
|
|
|
|
def _now(self) -> str:
|
|
return datetime.now(UTC).isoformat()
|
|
|
|
def _to_dict(self, model: BaseModel, exclude_none: bool = True) -> dict[str, Any]:
|
|
d = model.model_dump()
|
|
if exclude_none:
|
|
d = {k: v for k, v in d.items() if v is not None}
|
|
return d
|
|
|
|
|
|
class UserRepository(_BaseRepo):
|
|
TABLE = "profiles"
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def create(self, user: User) -> dict[str, Any]:
|
|
data = self._to_dict(user)
|
|
if not data.get("created_at"):
|
|
data["created_at"] = self._now()
|
|
result = self.db.table(self.TABLE).insert(data).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def get(self, user_id: str) -> dict[str, Any] | None:
|
|
result = self.db.table(self.TABLE).select("*").eq("id", user_id).execute()
|
|
return result.data[0] if result.data else None
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def get_by_wallet(self, wallet_address: str) -> dict[str, Any] | None:
|
|
result = self.db.table(self.TABLE).select("*").eq("wallet_address", wallet_address.lower()).execute()
|
|
return result.data[0] if result.data else None
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def list(self, limit: int = 100) -> list[dict[str, Any]]:
|
|
result = self.db.table(self.TABLE).select("*").limit(limit).execute()
|
|
return result.data or []
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def update(self, user_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
|
result = self.db.table(self.TABLE).update(updates).eq("id", user_id).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def upsert(self, user: User) -> dict[str, Any]:
|
|
data = self._to_dict(user)
|
|
if not data.get("created_at"):
|
|
data["created_at"] = self._now()
|
|
result = self.db.table(self.TABLE).upsert(data, on_conflict="id").execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def delete(self, user_id: str) -> bool:
|
|
self.db.table(self.TABLE).delete().eq("id", user_id).execute()
|
|
return True
|
|
|
|
|
|
class InvestigationCaseRepository(_BaseRepo):
|
|
TABLE = "cases"
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def create(self, case: InvestigationCase) -> dict[str, Any]:
|
|
data = self._to_dict(case)
|
|
if not data.get("created_at"):
|
|
data["created_at"] = self._now()
|
|
if not data.get("updated_at"):
|
|
data["updated_at"] = self._now()
|
|
result = self.db.table(self.TABLE).insert(data).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def get(self, case_id: str) -> dict[str, Any] | None:
|
|
result = self.db.table(self.TABLE).select("*").eq("id", case_id).execute()
|
|
return result.data[0] if result.data else None
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def list(self, limit: int = 100, status: str | None = None) -> list[dict[str, Any]]:
|
|
query = self.db.table(self.TABLE).select("*")
|
|
if status:
|
|
query = query.eq("status", status)
|
|
result = query.limit(limit).execute()
|
|
return result.data or []
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def update(self, case_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
|
updates["updated_at"] = self._now()
|
|
result = self.db.table(self.TABLE).update(updates).eq("id", case_id).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def delete(self, case_id: str) -> bool:
|
|
self.db.table(self.TABLE).delete().eq("id", case_id).execute()
|
|
return True
|
|
|
|
|
|
class WalletScanRepository(_BaseRepo):
|
|
TABLE = "wallet_intel"
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def create(self, scan: WalletScan) -> dict[str, Any]:
|
|
data = self._to_dict(scan)
|
|
# Map findings to transactions JSONB for schema compatibility
|
|
if "findings" in data:
|
|
data["transactions"] = data.pop("findings")
|
|
if not data.get("scanned_at"):
|
|
data["last_seen"] = self._now()
|
|
result = self.db.table(self.TABLE).insert(data).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def get(self, scan_id: str) -> dict[str, Any] | None:
|
|
result = self.db.table(self.TABLE).select("*").eq("id", scan_id).execute()
|
|
return result.data[0] if result.data else None
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def get_by_address(self, address: str) -> dict[str, Any] | None:
|
|
result = self.db.table(self.TABLE).select("*").eq("address", address).execute()
|
|
return result.data[0] if result.data else None
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def list(self, limit: int = 100, chain: str | None = None) -> list[dict[str, Any]]:
|
|
query = self.db.table(self.TABLE).select("*")
|
|
if chain:
|
|
query = query.eq("chain", chain)
|
|
result = query.limit(limit).execute()
|
|
return result.data or []
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def update(self, scan_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
|
if "findings" in updates:
|
|
updates["transactions"] = updates.pop("findings")
|
|
updates["updated_at"] = self._now()
|
|
result = self.db.table(self.TABLE).update(updates).eq("id", scan_id).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def delete(self, scan_id: str) -> bool:
|
|
self.db.table(self.TABLE).delete().eq("id", scan_id).execute()
|
|
return True
|
|
|
|
|
|
class ContractAuditRepository(_BaseRepo):
|
|
TABLE = "contract_audits"
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def create(self, audit: ContractAudit) -> dict[str, Any]:
|
|
data = self._to_dict(audit)
|
|
if not data.get("audited_at"):
|
|
data["audited_at"] = self._now()
|
|
result = self.db.table(self.TABLE).insert(data).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def get(self, audit_id: str) -> dict[str, Any] | None:
|
|
result = self.db.table(self.TABLE).select("*").eq("id", audit_id).execute()
|
|
return result.data[0] if result.data else None
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def list(self, limit: int = 100, chain: str | None = None) -> list[dict[str, Any]]:
|
|
query = self.db.table(self.TABLE).select("*")
|
|
if chain:
|
|
query = query.eq("chain", chain)
|
|
result = query.limit(limit).execute()
|
|
return result.data or []
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def update(self, audit_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
|
result = self.db.table(self.TABLE).update(updates).eq("id", audit_id).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def delete(self, audit_id: str) -> bool:
|
|
self.db.table(self.TABLE).delete().eq("id", audit_id).execute()
|
|
return True
|
|
|
|
|
|
class TrenchesPostRepository(_BaseRepo):
|
|
TABLE = "trenches_posts"
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def create(self, post: TrenchesPost) -> dict[str, Any]:
|
|
data = self._to_dict(post)
|
|
if not data.get("created_at"):
|
|
data["created_at"] = self._now()
|
|
result = self.db.table(self.TABLE).insert(data).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def get(self, post_id: str) -> dict[str, Any] | None:
|
|
result = self.db.table(self.TABLE).select("*").eq("id", post_id).execute()
|
|
return result.data[0] if result.data else None
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def list(self, limit: int = 50, category: str | None = None) -> list[dict[str, Any]]:
|
|
query = self.db.table(self.TABLE).select("*")
|
|
if category and category != "all":
|
|
query = query.eq("category", category)
|
|
result = query.order("created_at", desc=True).limit(limit).execute()
|
|
return result.data or []
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def update(self, post_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
|
result = self.db.table(self.TABLE).update(updates).eq("id", post_id).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def delete(self, post_id: str) -> bool:
|
|
self.db.table(self.TABLE).delete().eq("id", post_id).execute()
|
|
return True
|
|
|
|
|
|
class AlertRepository(_BaseRepo):
|
|
TABLE = "alerts"
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def create(self, alert: Alert) -> dict[str, Any]:
|
|
data = self._to_dict(alert)
|
|
# Map types -> alert_types for schema compatibility
|
|
if "types" in data:
|
|
data["alert_types"] = data.pop("types")
|
|
if not data.get("created_at"):
|
|
data["created_at"] = self._now()
|
|
result = self.db.table(self.TABLE).insert(data).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def get(self, alert_id: str) -> dict[str, Any] | None:
|
|
result = self.db.table(self.TABLE).select("*").eq("id", alert_id).execute()
|
|
return result.data[0] if result.data else None
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def list(self, limit: int = 100, active_only: bool = True) -> list[dict[str, Any]]:
|
|
query = self.db.table(self.TABLE).select("*")
|
|
if active_only:
|
|
query = query.eq("active", True)
|
|
result = query.limit(limit).execute()
|
|
return result.data or []
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def update(self, alert_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
|
if "types" in updates:
|
|
updates["alert_types"] = updates.pop("types")
|
|
result = self.db.table(self.TABLE).update(updates).eq("id", alert_id).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def delete(self, alert_id: str) -> bool:
|
|
self.db.table(self.TABLE).delete().eq("id", alert_id).execute()
|
|
return True
|
|
|
|
|
|
class GamificationProfileRepository(_BaseRepo):
|
|
TABLE = "gamification_profiles"
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def create(self, profile: GamificationProfile) -> dict[str, Any]:
|
|
data = self._to_dict(profile)
|
|
if not data.get("updated_at"):
|
|
data["updated_at"] = self._now()
|
|
result = self.db.table(self.TABLE).insert(data).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def get(self, user_id: str) -> dict[str, Any] | None:
|
|
result = self.db.table(self.TABLE).select("*").eq("user_id", user_id).execute()
|
|
return result.data[0] if result.data else None
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def list(self, limit: int = 100) -> list[dict[str, Any]]:
|
|
result = self.db.table(self.TABLE).select("*").limit(limit).execute()
|
|
return result.data or []
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def update(self, user_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
|
updates["updated_at"] = self._now()
|
|
result = self.db.table(self.TABLE).update(updates).eq("user_id", user_id).execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def upsert(self, profile: GamificationProfile) -> dict[str, Any]:
|
|
data = self._to_dict(profile)
|
|
if not data.get("updated_at"):
|
|
data["updated_at"] = self._now()
|
|
result = self.db.table(self.TABLE).upsert(data, on_conflict="user_id").execute()
|
|
return result.data[0] if result.data else {}
|
|
|
|
@_async_retry(max_retries=3)
|
|
async def delete(self, user_id: str) -> bool:
|
|
self.db.table(self.TABLE).delete().eq("user_id", user_id).execute()
|
|
return True
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# UNIFIED DB INTERFACE
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
class RmiDatabase:
|
|
"""Unified interface: Supabase persistence + Redis cache."""
|
|
|
|
def __init__(self, supabase_url: str | None = None, supabase_key: str | None = None):
|
|
self.db = SupabaseClient(url=supabase_url, key=supabase_key)
|
|
self.users = UserRepository(self.db)
|
|
self.cases = InvestigationCaseRepository(self.db)
|
|
self.wallet_scans = WalletScanRepository(self.db)
|
|
self.contract_audits = ContractAuditRepository(self.db)
|
|
self.trenches_posts = TrenchesPostRepository(self.db)
|
|
self.alerts = AlertRepository(self.db)
|
|
self.gamification = GamificationProfileRepository(self.db)
|
|
|
|
async def ensure_tables(self) -> None:
|
|
await self.db.ensure_tables()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# REDIS CACHE HELPERS
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
_CACHE_TTL = 300 # 5 minutes default
|
|
|
|
|
|
async def _get_redis() -> Any | None:
|
|
"""Lazy Redis client (mirrors main.py pattern)."""
|
|
if redis is None:
|
|
return None
|
|
try:
|
|
import os
|
|
|
|
host = os.getenv("REDIS_HOST", "127.0.0.1")
|
|
port = int(os.getenv("REDIS_PORT", "6379"))
|
|
password = os.getenv("REDIS_PASSWORD") or None
|
|
db = int(os.getenv("REDIS_DB", "0"))
|
|
return redis.Redis(host=host, port=port, password=password, db=db, decode_responses=True)
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] Redis init failed: {exc}")
|
|
return None
|
|
|
|
|
|
async def cache_set(key: str, value: Any, ttl: int = _CACHE_TTL) -> None:
|
|
r = await _get_redis()
|
|
if r is None:
|
|
return
|
|
try:
|
|
await r.set(key, json.dumps(value), ex=ttl)
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] cache_set failed: {exc}")
|
|
|
|
|
|
async def cache_get(key: str) -> Any | None:
|
|
r = await _get_redis()
|
|
if r is None:
|
|
return None
|
|
try:
|
|
raw = await r.get(key)
|
|
return json.loads(raw) if raw else None
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] cache_get failed: {exc}")
|
|
return None
|
|
|
|
|
|
async def cache_delete(key: str) -> None:
|
|
r = await _get_redis()
|
|
if r is None:
|
|
return
|
|
try:
|
|
await r.delete(key)
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] cache_delete failed: {exc}")
|
|
|
|
|
|
async def cache_hash_set(hash_name: str, field: str, value: Any) -> None:
|
|
r = await _get_redis()
|
|
if r is None:
|
|
return
|
|
try:
|
|
await r.hset(hash_name, field, json.dumps(value))
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] cache_hash_set failed: {exc}")
|
|
|
|
|
|
async def cache_hash_get_all(hash_name: str) -> dict[str, Any]:
|
|
r = await _get_redis()
|
|
if r is None:
|
|
return {}
|
|
try:
|
|
raw = await r.hgetall(hash_name)
|
|
return {k: json.loads(v) for k, v in raw.items()}
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] cache_hash_get_all failed: {exc}")
|
|
return {}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# MIGRATION HELPER
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
|
|
async def sync_redis_to_supabase() -> dict[str, int]:
|
|
"""
|
|
Read all data from Redis hashes and upsert into Supabase tables.
|
|
Returns counts of synced records per table.
|
|
"""
|
|
counts = {"cases": 0, "trenches_posts": 0, "alerts": 0, "tasks": 0}
|
|
r = await _get_redis()
|
|
if r is None:
|
|
logger.warning("[DB] Redis unavailable; nothing to migrate")
|
|
return counts
|
|
|
|
db = RmiDatabase()
|
|
|
|
# ── Cases ──
|
|
try:
|
|
cases_raw = await r.hgetall("rmi:cases")
|
|
for case_json in cases_raw.values():
|
|
try:
|
|
data = json.loads(case_json)
|
|
case = InvestigationCase(
|
|
id=data.get("id", ""),
|
|
target=data.get("target", ""),
|
|
type=data.get("type", "wallet"),
|
|
status=data.get("status", "open"),
|
|
evidence=data.get("evidence") or data.get("evidence", []),
|
|
agents_assigned=data.get("agents_assigned") or data.get("agents_assigned", []),
|
|
created_at=data.get("created_at") or data.get("created"),
|
|
updated_at=data.get("updated_at") or data.get("updated") or data.get("created"),
|
|
risk_score=float(data.get("risk_score", 0)),
|
|
findings=data.get("findings") or {},
|
|
)
|
|
await db.cases.create(case)
|
|
counts["cases"] += 1
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] Failed to migrate case: {exc}")
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] Cases migration error: {exc}")
|
|
|
|
# ── Trenches Posts ──
|
|
try:
|
|
posts_raw = await r.hgetall("rmi:trenches:posts")
|
|
for post_json in posts_raw.values():
|
|
try:
|
|
data = json.loads(post_json)
|
|
post = TrenchesPost(
|
|
id=data.get("id"),
|
|
title=data.get("title", ""),
|
|
content=data.get("content", ""),
|
|
category=data.get("category", "discussion"),
|
|
author_id=data.get("author_id"),
|
|
upvotes=int(data.get("upvotes", 0)),
|
|
comments=int(data.get("comments", 0)),
|
|
tags=data.get("tags") or [],
|
|
created_at=data.get("created_at") or data.get("created"),
|
|
)
|
|
await db.trenches_posts.create(post)
|
|
counts["trenches_posts"] += 1
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] Failed to migrate post: {exc}")
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] Posts migration error: {exc}")
|
|
|
|
# ── Alerts ──
|
|
try:
|
|
alerts_raw = await r.hgetall("rmi:alerts")
|
|
for alert_json in alerts_raw.values():
|
|
try:
|
|
data = json.loads(alert_json)
|
|
alert = Alert(
|
|
id=data.get("id"),
|
|
token_address=data.get("token") or data.get("token_address", ""),
|
|
types=data.get("types") or data.get("alert_types") or [],
|
|
webhook_url=data.get("webhook") or data.get("webhook_url"),
|
|
active=data.get("active", True),
|
|
created_at=data.get("created_at") or data.get("created"),
|
|
)
|
|
await db.alerts.create(alert)
|
|
counts["alerts"] += 1
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] Failed to migrate alert: {exc}")
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] Alerts migration error: {exc}")
|
|
|
|
# ── Tasks (migrate as cases with type=task for traceability) ──
|
|
try:
|
|
tasks_raw = await r.hgetall("rmi:tasks")
|
|
for task_json in tasks_raw.values():
|
|
try:
|
|
data = json.loads(task_json)
|
|
case = InvestigationCase(
|
|
id=data.get("id", ""),
|
|
target=data.get("command", "task"),
|
|
type="task",
|
|
status=data.get("status", "open"),
|
|
evidence=[],
|
|
agents_assigned=[data.get("agent", "")] if data.get("agent") else [],
|
|
created_at=data.get("created") or data.get("created_at"),
|
|
updated_at=data.get("created") or data.get("created_at"),
|
|
risk_score=0.0,
|
|
findings={
|
|
"context": data.get("context", {}),
|
|
"priority": data.get("priority", "normal"),
|
|
},
|
|
)
|
|
await db.cases.create(case)
|
|
counts["tasks"] += 1
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] Failed to migrate task: {exc}")
|
|
except Exception as exc:
|
|
logger.warning(f"[DB] Tasks migration error: {exc}")
|
|
|
|
logger.info(f"[DB] Migration complete: {counts}")
|
|
return counts
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# SINGLETON INSTANCE
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
_rmi_db: RmiDatabase | None = None
|
|
|
|
|
|
def get_db() -> RmiDatabase:
|
|
global _rmi_db
|
|
if _rmi_db is None:
|
|
_rmi_db = RmiDatabase()
|
|
return _rmi_db
|