feat(alerts): vertical slice — pure-Python domain + thin v1 route
Browse filesapp/domain/alerts/ (NO FastAPI imports, pure business logic):
models.py Pydantic v2: AlertType enum, AlertSubscription, CreateAlertRequest, AlertEvent
repository.py Async Redis (hset/hgetall) — uses core/redis
service.py CRUD + fire_event + severity mapping
broadcaster.py WebSocket push via core/websocket
__init__.py Public API: AlertService, AlertRepository, AlertBroadcaster, models
app/api/v1/auth/alerts.py (THIN route, <100 lines):
POST /api/v1/alerts/subscribe
GET /api/v1/alerts
DELETE /api/v1/alerts/{alert_id}
Uses get_optional_user from core/auth (delegates to legacy JWT verify).
app/api/v1/__init__.py — aggregator mounts the new alerts router alongside legacy.
app/core/auth.py — added get_optional_user + get_current_user JWT deps,
delegating to legacy app.auth (stranglerfig).
tests/unit/domain/alerts/test_service.py — 7 unit tests, all pass:
test_create_subscription_assigns_id_and_persists
test_create_subscription_with_explicit_types
test_list_filters_by_owner
test_cancel_unauthorized_returns_false
test_cancel_owner_succeeds
test_fire_event_broadcasts_to_matching_subs
test_severity_mapping
PROVES THE PATTERN:
- Domain layer is unit-testable WITHOUT spinning up FastAPI.
- Service uses core/redis (single source of truth) — no scattered get_redis().
- Service uses core/logging (structlog) — no scattered logging.basicConfig.
- Service uses core/auth (single source of truth) — no scattered JWT decode.
- Route is THIN: parse → call service → return. No business logic in HTTP.
- Pydantic v2 models in domain, no dict types crossing boundaries.
- <500 lines per file (largest is service.py at ~150 lines).
Backend healthy, all 3 new routes in openapi, legacy /api/v1/alerts/* still
serving (stranglerfig — both work side-by-side, legacy wins on path conflict).
- backend/app/api/v1/__init__.py +14 -0
- backend/app/api/v1/auth/alerts.py +70 -0
- backend/app/core/auth.py +35 -2
- backend/app/domain/__init__.py +15 -0
- backend/app/domain/alerts/__init__.py +32 -0
- backend/app/domain/alerts/broadcaster.py +27 -0
- backend/app/domain/alerts/models.py +71 -0
- backend/app/domain/alerts/repository.py +60 -0
- backend/app/domain/alerts/service.py +133 -0
- backend/tests/unit/domain/__init__.py +0 -0
- backend/tests/unit/domain/alerts/__init__.py +0 -0
- backend/tests/unit/domain/alerts/test_service.py +129 -0
|
@@ -24,6 +24,20 @@ api_v1_router: list[APIRouter] = []
|
|
| 24 |
router = APIRouter(prefix="/api/v1", tags=["v1"])
|
| 25 |
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def build_v1_router() -> APIRouter:
|
| 28 |
"""Construct the v1 aggregator with all migrated routes mounted."""
|
| 29 |
aggregated = APIRouter(prefix="/api/v1")
|
|
|
|
| 24 |
router = APIRouter(prefix="/api/v1", tags=["v1"])
|
| 25 |
|
| 26 |
|
| 27 |
+
# ── Migrated domains ───────────────────────────────────────────────────
|
| 28 |
+
# Each migrated domain is imported here. The router exposes endpoints
|
| 29 |
+
# at /api/v1/<domain>/* (path defined per-router).
|
| 30 |
+
#
|
| 31 |
+
# During strangelfig, the LEGACY /api/v1/alerts/* endpoints remain
|
| 32 |
+
# mounted in main.py. The new v1 router is mounted at the same path
|
| 33 |
+
# (FastAPI handles prefix-based routing) — first match wins, so the
|
| 34 |
+
# legacy stays until we explicitly remove it.
|
| 35 |
+
|
| 36 |
+
from app.api.v1.auth.alerts import router as alerts_router # noqa: E402
|
| 37 |
+
|
| 38 |
+
api_v1_router.append(alerts_router)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
def build_v1_router() -> APIRouter:
|
| 42 |
"""Construct the v1 aggregator with all migrated routes mounted."""
|
| 43 |
aggregated = APIRouter(prefix="/api/v1")
|
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V1 alerts route — thin HTTP layer over app.domain.alerts.
|
| 2 |
+
|
| 3 |
+
Rules (per 2026 standards):
|
| 4 |
+
- Parse request → call service → return response. No business logic here.
|
| 5 |
+
- Pydantic models for request/response, defined in app.domain.alerts.models.
|
| 6 |
+
- No direct Redis or DB access. Goes through AlertService.
|
| 7 |
+
- All errors raised as AppError subclasses, handled by core.errors handlers.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Annotated, Any
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, Depends
|
| 14 |
+
|
| 15 |
+
from app.core.auth import get_optional_user
|
| 16 |
+
from app.core.errors import AuthError, NotFoundError
|
| 17 |
+
from app.domain.alerts import (
|
| 18 |
+
AlertService,
|
| 19 |
+
AlertSubscription,
|
| 20 |
+
CreateAlertRequest,
|
| 21 |
+
)
|
| 22 |
+
from app.models import PaginatedResponse
|
| 23 |
+
|
| 24 |
+
router = APIRouter(prefix="/api/v1/alerts", tags=["alerts"])
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _service() -> AlertService:
|
| 28 |
+
"""Dependency: a fresh AlertService per request (stateless)."""
|
| 29 |
+
return AlertService()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post("/subscribe", response_model=AlertSubscription, status_code=201)
|
| 33 |
+
async def subscribe(
|
| 34 |
+
req: CreateAlertRequest,
|
| 35 |
+
user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
|
| 36 |
+
svc: Annotated[AlertService, Depends(_service)],
|
| 37 |
+
) -> AlertSubscription:
|
| 38 |
+
"""Create a new alert subscription for a token."""
|
| 39 |
+
owner_id = (user or {}).get("id")
|
| 40 |
+
return await svc.create_subscription(req, owner_id=owner_id)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.get("", response_model=PaginatedResponse)
|
| 44 |
+
async def list_alerts(
|
| 45 |
+
user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
|
| 46 |
+
svc: Annotated[AlertService, Depends(_service)],
|
| 47 |
+
) -> PaginatedResponse:
|
| 48 |
+
"""List alert subscriptions. Authenticated users see their own; anonymous sees all (legacy behavior)."""
|
| 49 |
+
owner_id = (user or {}).get("id")
|
| 50 |
+
items = await svc.list_subscriptions(owner_id=owner_id)
|
| 51 |
+
return PaginatedResponse(
|
| 52 |
+
items=[i.model_dump(mode="json") for i in items],
|
| 53 |
+
total=len(items),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.delete("/{alert_id}", status_code=204)
|
| 58 |
+
async def cancel(
|
| 59 |
+
alert_id: str,
|
| 60 |
+
user: Annotated[dict[str, Any] | None, Depends(get_optional_user)],
|
| 61 |
+
svc: Annotated[AlertService, Depends(_service)],
|
| 62 |
+
) -> None:
|
| 63 |
+
"""Cancel (delete) a subscription. Owner only."""
|
| 64 |
+
owner_id = (user or {}).get("id")
|
| 65 |
+
if owner_id is None:
|
| 66 |
+
raise AuthError("authentication required to cancel alerts")
|
| 67 |
+
ok = await svc.cancel_subscription(alert_id, owner_id=owner_id)
|
| 68 |
+
if not ok:
|
| 69 |
+
raise NotFoundError(f"alert {alert_id} not found or not owned by you")
|
| 70 |
+
return None
|
|
@@ -1,6 +1,13 @@
|
|
| 1 |
-
"""RMI Backend — Auth middleware
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import os
|
|
|
|
| 4 |
|
| 5 |
from fastapi import Request
|
| 6 |
from fastapi.responses import JSONResponse
|
|
@@ -27,7 +34,6 @@ PUBLIC_WRITE_PREFIXES = [
|
|
| 27 |
"/api/v1/wallet-manager/",
|
| 28 |
]
|
| 29 |
|
| 30 |
-
# Auth bypass paths
|
| 31 |
PUBLIC_GET_PREFIXES = [
|
| 32 |
"/api/v1/token/",
|
| 33 |
"/api/v1/databus/",
|
|
@@ -63,3 +69,30 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
|
| 63 |
content={"detail": "Unauthorized - valid X-API-Key header required for write operations"},
|
| 64 |
)
|
| 65 |
return await call_next(request)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RMI Backend — Auth middleware, API key verification, and JWT user identity.
|
| 2 |
+
|
| 3 |
+
This module is the single source of truth for auth. Routes import
|
| 4 |
+
`get_current_user` / `get_optional_user` from here (re-exported via
|
| 5 |
+
app.api.deps for convenience).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
|
| 9 |
import os
|
| 10 |
+
from typing import Any
|
| 11 |
|
| 12 |
from fastapi import Request
|
| 13 |
from fastapi.responses import JSONResponse
|
|
|
|
| 34 |
"/api/v1/wallet-manager/",
|
| 35 |
]
|
| 36 |
|
|
|
|
| 37 |
PUBLIC_GET_PREFIXES = [
|
| 38 |
"/api/v1/token/",
|
| 39 |
"/api/v1/databus/",
|
|
|
|
| 69 |
content={"detail": "Unauthorized - valid X-API-Key header required for write operations"},
|
| 70 |
)
|
| 71 |
return await call_next(request)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ── JWT user identity (FastAPI dependencies) ─────────────────────────────
|
| 75 |
+
# Delegates to the legacy app.auth JWT logic during strangelfig migration.
|
| 76 |
+
# Once legacy auth.py is migrated to the new pattern, these become the
|
| 77 |
+
# canonical implementation. Until then they reuse the working logic so
|
| 78 |
+
# new routes (like app/api/v1/auth/alerts.py) can use modern Depends().
|
| 79 |
+
|
| 80 |
+
async def get_optional_user(request: Request) -> dict[str, Any] | None:
|
| 81 |
+
"""Return the authenticated user dict, or None if not authenticated.
|
| 82 |
+
|
| 83 |
+
Reads the Authorization: Bearer <jwt> header. Returns the user dict
|
| 84 |
+
(id, email, tier, role) on success, None if no/invalid token.
|
| 85 |
+
|
| 86 |
+
Use this for endpoints that work with OR without auth.
|
| 87 |
+
"""
|
| 88 |
+
from app.auth import get_current_user as _legacy_get_user # local import to avoid cycles
|
| 89 |
+
return await _legacy_get_user(request)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def get_current_user(request: Request) -> dict[str, Any]:
|
| 93 |
+
"""Require an authenticated user. Raises 401 if missing.
|
| 94 |
+
|
| 95 |
+
Use this for endpoints that REQUIRE auth.
|
| 96 |
+
"""
|
| 97 |
+
from app.auth import require_auth as _legacy_require # local import to avoid cycles
|
| 98 |
+
return await _legacy_require(request)
|
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure business logic. NO FastAPI imports allowed.
|
| 2 |
+
|
| 3 |
+
Per-domain packages (each follows: models.py + service.py + helpers):
|
| 4 |
+
- scanner: token scanning, honeypot, rugcheck, holders, contract
|
| 5 |
+
- wallet: wallet analysis, labels, behavior
|
| 6 |
+
- token: token discovery, supply, metadata
|
| 7 |
+
- rag: embeddings, search, ingest, firehose, feedback, agentic
|
| 8 |
+
- x402: facilitator, settlement, enforcement
|
| 9 |
+
- intel: feeds, narratives, graph
|
| 10 |
+
- scam: classifier, patterns
|
| 11 |
+
- databus: client, chain registry (96 chains)
|
| 12 |
+
- bulletin: user-generated content board
|
| 13 |
+
|
| 14 |
+
Migration order: alerts (smoke test) → wallet → token → scanner → x402 → rag.
|
| 15 |
+
"""
|
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Alerts domain — token alert subscriptions and event broadcasting.
|
| 2 |
+
|
| 3 |
+
Public API:
|
| 4 |
+
from app.domain.alerts import (
|
| 5 |
+
AlertSubscription, AlertEvent, CreateAlertRequest, AlertType,
|
| 6 |
+
AlertService, AlertRepository, AlertBroadcaster,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
The domain layer is pure Python. NO FastAPI imports. NO HTTP concerns.
|
| 10 |
+
This module is the only thing the api/ layer should import from.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from app.domain.alerts.broadcaster import AlertBroadcaster
|
| 15 |
+
from app.domain.alerts.models import (
|
| 16 |
+
AlertEvent,
|
| 17 |
+
AlertSubscription,
|
| 18 |
+
AlertType,
|
| 19 |
+
CreateAlertRequest,
|
| 20 |
+
)
|
| 21 |
+
from app.domain.alerts.repository import AlertRepository
|
| 22 |
+
from app.domain.alerts.service import AlertService
|
| 23 |
+
|
| 24 |
+
__all__ = [
|
| 25 |
+
"AlertEvent",
|
| 26 |
+
"AlertSubscription",
|
| 27 |
+
"AlertType",
|
| 28 |
+
"CreateAlertRequest",
|
| 29 |
+
"AlertRepository",
|
| 30 |
+
"AlertService",
|
| 31 |
+
"AlertBroadcaster",
|
| 32 |
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Broadcaster — pushes fired alert events to WebSocket subscribers.
|
| 2 |
+
|
| 3 |
+
Uses core/websocket's broadcast helper. No FastAPI imports.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from app.core.logging import get_logger
|
| 8 |
+
from app.core.websocket import broadcast_alert
|
| 9 |
+
from app.domain.alerts.models import AlertEvent
|
| 10 |
+
|
| 11 |
+
log = get_logger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AlertBroadcaster:
|
| 15 |
+
"""Thin wrapper that knows how to push an AlertEvent to live clients."""
|
| 16 |
+
|
| 17 |
+
async def broadcast_event(self, event: AlertEvent) -> None:
|
| 18 |
+
"""Broadcast a single event to all connected WebSocket subscribers."""
|
| 19 |
+
try:
|
| 20 |
+
await broadcast_alert(event.model_dump(mode="json"))
|
| 21 |
+
except Exception as e:
|
| 22 |
+
# Broadcasting failure must not break the firing path.
|
| 23 |
+
log.warning(
|
| 24 |
+
"alert_broadcast_failed",
|
| 25 |
+
error=str(e),
|
| 26 |
+
alert_id=event.subscription_id,
|
| 27 |
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic v2 models for the alerts domain.
|
| 2 |
+
|
| 3 |
+
No FastAPI imports. Pure data shapes.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AlertType(str, Enum):
|
| 15 |
+
"""Valid alert trigger types. Matches the legacy alert_types enum."""
|
| 16 |
+
|
| 17 |
+
LIQUIDITY_REMOVE = "liquidity_remove"
|
| 18 |
+
MINT = "mint"
|
| 19 |
+
BLACKLIST = "blacklist"
|
| 20 |
+
HONEYPOT = "honeypot"
|
| 21 |
+
RUG_PULL = "rug_pull"
|
| 22 |
+
WHALE_MOVEMENT = "whale_movement"
|
| 23 |
+
DEPLOYER_SELL = "deployer_sell"
|
| 24 |
+
|
| 25 |
+
@classmethod
|
| 26 |
+
def values(cls) -> list[str]:
|
| 27 |
+
return [m.value for m in cls]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class AlertSubscription(BaseModel):
|
| 31 |
+
"""A token alert subscription record (what we store in Redis)."""
|
| 32 |
+
|
| 33 |
+
model_config = ConfigDict(str_strip_whitespace=True)
|
| 34 |
+
|
| 35 |
+
id: str = Field(..., description="Unique subscription id, e.g. alert:1700000000")
|
| 36 |
+
token_address: str = Field(..., min_length=1, max_length=256)
|
| 37 |
+
alert_types: list[AlertType] = Field(default_factory=lambda: [AlertType.LIQUIDITY_REMOVE, AlertType.MINT, AlertType.BLACKLIST])
|
| 38 |
+
webhook_url: str | None = Field(default=None, max_length=2048)
|
| 39 |
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 40 |
+
active: bool = True
|
| 41 |
+
owner_id: str | None = Field(default=None, description="User id of subscription owner, None = anonymous")
|
| 42 |
+
|
| 43 |
+
@field_validator("alert_types")
|
| 44 |
+
@classmethod
|
| 45 |
+
def _at_least_one_type(cls, v: list[AlertType]) -> list[AlertType]:
|
| 46 |
+
if not v:
|
| 47 |
+
raise ValueError("at least one alert_type required")
|
| 48 |
+
return v
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class CreateAlertRequest(BaseModel):
|
| 52 |
+
"""What the API accepts to create a subscription."""
|
| 53 |
+
|
| 54 |
+
model_config = ConfigDict(str_strip_whitespace=True)
|
| 55 |
+
|
| 56 |
+
token_address: str = Field(..., min_length=1, max_length=256)
|
| 57 |
+
alert_types: list[AlertType] | None = None
|
| 58 |
+
webhook_url: str | None = Field(default=None, max_length=2048)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class AlertEvent(BaseModel):
|
| 62 |
+
"""A fired alert, ready to broadcast to subscribers + WebSocket clients."""
|
| 63 |
+
|
| 64 |
+
model_config = ConfigDict(use_enum_values=True)
|
| 65 |
+
|
| 66 |
+
subscription_id: str
|
| 67 |
+
token_address: str
|
| 68 |
+
event_type: AlertType
|
| 69 |
+
payload: dict[str, Any] = Field(default_factory=dict)
|
| 70 |
+
fired_at: datetime = Field(default_factory=datetime.utcnow)
|
| 71 |
+
severity: str = Field(default="info", description="info | warning | critical")
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Repository — async Redis storage for alert subscriptions.
|
| 2 |
+
|
| 3 |
+
Storage layout (matches legacy for cutover):
|
| 4 |
+
Hash key: "rmi:alerts"
|
| 5 |
+
Field: alert id (e.g. "alert:1700000000")
|
| 6 |
+
Value: JSON-encoded AlertSubscription
|
| 7 |
+
|
| 8 |
+
This is a thin async wrapper. No business logic. Pure data access.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from app.core.logging import get_logger
|
| 13 |
+
from app.core.redis import get_redis_async
|
| 14 |
+
from app.domain.alerts.models import AlertSubscription
|
| 15 |
+
|
| 16 |
+
log = get_logger(__name__)
|
| 17 |
+
|
| 18 |
+
HASH_KEY = "rmi:alerts"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class AlertRepository:
|
| 22 |
+
"""Async Redis-backed subscription storage."""
|
| 23 |
+
|
| 24 |
+
def __init__(self) -> None:
|
| 25 |
+
self._key = HASH_KEY
|
| 26 |
+
|
| 27 |
+
async def list_all(self) -> list[AlertSubscription]:
|
| 28 |
+
r = get_redis_async()
|
| 29 |
+
raw: dict[str, str] = await r.hgetall(self._key) or {}
|
| 30 |
+
out: list[AlertSubscription] = []
|
| 31 |
+
for value in raw.values():
|
| 32 |
+
try:
|
| 33 |
+
out.append(AlertSubscription.model_validate_json(value))
|
| 34 |
+
except Exception as e:
|
| 35 |
+
log.warning("alert_repo_corrupt_record", error=str(e))
|
| 36 |
+
return out
|
| 37 |
+
|
| 38 |
+
async def get(self, alert_id: str) -> AlertSubscription | None:
|
| 39 |
+
r = get_redis_async()
|
| 40 |
+
raw: str | None = await r.hget(self._key, alert_id)
|
| 41 |
+
if raw is None:
|
| 42 |
+
return None
|
| 43 |
+
try:
|
| 44 |
+
return AlertSubscription.model_validate_json(raw)
|
| 45 |
+
except Exception as e:
|
| 46 |
+
log.warning("alert_repo_corrupt_record", alert_id=alert_id, error=str(e))
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
async def create(self, sub: AlertSubscription) -> None:
|
| 50 |
+
r = get_redis_async()
|
| 51 |
+
await r.hset(self._key, sub.id, sub.model_dump_json())
|
| 52 |
+
|
| 53 |
+
async def delete(self, alert_id: str) -> bool:
|
| 54 |
+
r = get_redis_async()
|
| 55 |
+
removed: int = await r.hdel(self._key, alert_id)
|
| 56 |
+
return removed > 0
|
| 57 |
+
|
| 58 |
+
async def list_by_token(self, token_address: str) -> list[AlertSubscription]:
|
| 59 |
+
all_subs = await self.list_all()
|
| 60 |
+
return [s for s in all_subs if s.token_address == token_address]
|
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Service — business logic for alert subscriptions and event firing.
|
| 2 |
+
|
| 3 |
+
This is the only thing the api/ layer should call. It composes
|
| 4 |
+
the repository (storage) and the broadcaster (WebSocket push).
|
| 5 |
+
No FastAPI. No HTTP. Pure Python.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
from app.core.logging import get_logger
|
| 12 |
+
from app.domain.alerts.broadcaster import AlertBroadcaster
|
| 13 |
+
from app.domain.alerts.models import (
|
| 14 |
+
AlertEvent,
|
| 15 |
+
AlertSubscription,
|
| 16 |
+
AlertType,
|
| 17 |
+
CreateAlertRequest,
|
| 18 |
+
)
|
| 19 |
+
from app.domain.alerts.repository import AlertRepository
|
| 20 |
+
|
| 21 |
+
log = get_logger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class AlertService:
|
| 25 |
+
"""Orchestrates subscription CRUD and event firing."""
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
repo: AlertRepository | None = None,
|
| 30 |
+
broadcaster: AlertBroadcaster | None = None,
|
| 31 |
+
) -> None:
|
| 32 |
+
self._repo = repo or AlertRepository()
|
| 33 |
+
self._broadcaster = broadcaster or AlertBroadcaster()
|
| 34 |
+
|
| 35 |
+
async def create_subscription(
|
| 36 |
+
self,
|
| 37 |
+
req: CreateAlertRequest,
|
| 38 |
+
owner_id: str | None = None,
|
| 39 |
+
) -> AlertSubscription:
|
| 40 |
+
"""Create a new subscription, persist it, return the saved record."""
|
| 41 |
+
# Use provided types, or the model default if not given.
|
| 42 |
+
types = req.alert_types or [
|
| 43 |
+
AlertType.LIQUIDITY_REMOVE,
|
| 44 |
+
AlertType.MINT,
|
| 45 |
+
AlertType.BLACKLIST,
|
| 46 |
+
]
|
| 47 |
+
sub = AlertSubscription(
|
| 48 |
+
id=f"alert:{int(datetime.utcnow().timestamp())}",
|
| 49 |
+
token_address=req.token_address,
|
| 50 |
+
alert_types=types,
|
| 51 |
+
webhook_url=req.webhook_url,
|
| 52 |
+
owner_id=owner_id,
|
| 53 |
+
)
|
| 54 |
+
await self._repo.create(sub)
|
| 55 |
+
log.info(
|
| 56 |
+
"alert_subscription_created",
|
| 57 |
+
alert_id=sub.id,
|
| 58 |
+
token=sub.token_address,
|
| 59 |
+
types=[t.value for t in sub.alert_types],
|
| 60 |
+
owner=owner_id,
|
| 61 |
+
)
|
| 62 |
+
return sub
|
| 63 |
+
|
| 64 |
+
async def list_subscriptions(self, owner_id: str | None = None) -> list[AlertSubscription]:
|
| 65 |
+
"""List subscriptions. If owner_id given, filter to that user."""
|
| 66 |
+
all_subs = await self._repo.list_all()
|
| 67 |
+
if owner_id is None:
|
| 68 |
+
return all_subs
|
| 69 |
+
return [s for s in all_subs if s.owner_id == owner_id]
|
| 70 |
+
|
| 71 |
+
async def cancel_subscription(self, alert_id: str, owner_id: str | None = None) -> bool:
|
| 72 |
+
"""Cancel (delete) a subscription. Returns True if removed."""
|
| 73 |
+
sub = await self._repo.get(alert_id)
|
| 74 |
+
if sub is None:
|
| 75 |
+
return False
|
| 76 |
+
if owner_id is not None and sub.owner_id != owner_id:
|
| 77 |
+
log.warning(
|
| 78 |
+
"alert_cancel_unauthorized",
|
| 79 |
+
alert_id=alert_id,
|
| 80 |
+
owner=owner_id,
|
| 81 |
+
actual_owner=sub.owner_id,
|
| 82 |
+
)
|
| 83 |
+
return False
|
| 84 |
+
return await self._repo.delete(alert_id)
|
| 85 |
+
|
| 86 |
+
async def fire_event(
|
| 87 |
+
self,
|
| 88 |
+
token_address: str,
|
| 89 |
+
event_type: AlertType,
|
| 90 |
+
payload: dict | None = None,
|
| 91 |
+
) -> list[AlertEvent]:
|
| 92 |
+
"""Fire an event for a token. Returns the events that were broadcast.
|
| 93 |
+
|
| 94 |
+
Looks up all active subscriptions matching this token + event_type,
|
| 95 |
+
creates an AlertEvent for each, and broadcasts via the broadcaster.
|
| 96 |
+
"""
|
| 97 |
+
subs = await self._repo.list_by_token(token_address)
|
| 98 |
+
matching = [
|
| 99 |
+
s for s in subs
|
| 100 |
+
if s.active and (event_type.value in [t.value for t in s.alert_types])
|
| 101 |
+
]
|
| 102 |
+
if not matching:
|
| 103 |
+
return []
|
| 104 |
+
|
| 105 |
+
events: list[AlertEvent] = []
|
| 106 |
+
for sub in matching:
|
| 107 |
+
ev = AlertEvent(
|
| 108 |
+
subscription_id=sub.id,
|
| 109 |
+
token_address=token_address,
|
| 110 |
+
event_type=event_type,
|
| 111 |
+
payload=payload or {},
|
| 112 |
+
severity=_severity_for(event_type),
|
| 113 |
+
)
|
| 114 |
+
events.append(ev)
|
| 115 |
+
await self._broadcaster.broadcast_event(ev)
|
| 116 |
+
log.info(
|
| 117 |
+
"alert_events_fired",
|
| 118 |
+
token=token_address,
|
| 119 |
+
event_type=event_type.value,
|
| 120 |
+
count=len(events),
|
| 121 |
+
)
|
| 122 |
+
return events
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _severity_for(event_type: AlertType) -> str:
|
| 126 |
+
"""Map event type → severity (used by clients to choose alert UI)."""
|
| 127 |
+
critical = {AlertType.RUG_PULL, AlertType.HONEYPOT, AlertType.BLACKLIST}
|
| 128 |
+
warning = {AlertType.LIQUIDITY_REMOVE, AlertType.WHALE_MOVEMENT, AlertType.DEPLOYER_SELL}
|
| 129 |
+
if event_type in critical:
|
| 130 |
+
return "critical"
|
| 131 |
+
if event_type in warning:
|
| 132 |
+
return "warning"
|
| 133 |
+
return "info"
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the alerts domain.
|
| 2 |
+
|
| 3 |
+
Pure-Python tests. No FastAPI. No HTTP. Imports the domain directly.
|
| 4 |
+
This is the test that proves the architecture: domain code is
|
| 5 |
+
unit-testable without spinning up the API.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from unittest.mock import AsyncMock
|
| 11 |
+
|
| 12 |
+
import pytest
|
| 13 |
+
|
| 14 |
+
from app.domain.alerts import (
|
| 15 |
+
AlertBroadcaster,
|
| 16 |
+
AlertEvent,
|
| 17 |
+
AlertRepository,
|
| 18 |
+
AlertService,
|
| 19 |
+
AlertSubscription,
|
| 20 |
+
AlertType,
|
| 21 |
+
CreateAlertRequest,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@pytest.fixture
|
| 26 |
+
def fake_repo() -> AsyncMock:
|
| 27 |
+
repo = AsyncMock(spec=AlertRepository)
|
| 28 |
+
repo.list_all = AsyncMock(return_value=[])
|
| 29 |
+
repo.get = AsyncMock(return_value=None)
|
| 30 |
+
repo.create = AsyncMock(return_value=None)
|
| 31 |
+
repo.delete = AsyncMock(return_value=True)
|
| 32 |
+
repo.list_by_token = AsyncMock(return_value=[])
|
| 33 |
+
return repo
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@pytest.fixture
|
| 37 |
+
def fake_broadcaster() -> AsyncMock:
|
| 38 |
+
bc = AsyncMock(spec=AlertBroadcaster)
|
| 39 |
+
bc.broadcast_event = AsyncMock(return_value=None)
|
| 40 |
+
return bc
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@pytest.fixture
|
| 44 |
+
def service(fake_repo: AsyncMock, fake_broadcaster: AsyncMock) -> AlertService:
|
| 45 |
+
return AlertService(repo=fake_repo, broadcaster=fake_broadcaster)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def test_create_subscription_assigns_id_and_persists(service, fake_repo):
|
| 49 |
+
req = CreateAlertRequest(token_address="0xabc")
|
| 50 |
+
sub = await service.create_subscription(req, owner_id="user-1")
|
| 51 |
+
assert sub.id.startswith("alert:")
|
| 52 |
+
assert sub.token_address == "0xabc"
|
| 53 |
+
assert sub.owner_id == "user-1"
|
| 54 |
+
assert sub.active is True
|
| 55 |
+
# Default alert types applied when none provided
|
| 56 |
+
assert AlertType.LIQUIDITY_REMOVE in [AlertType(t) for t in sub.alert_types]
|
| 57 |
+
fake_repo.create.assert_awaited_once()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def test_create_subscription_with_explicit_types(service, fake_repo):
|
| 61 |
+
req = CreateAlertRequest(
|
| 62 |
+
token_address="0xdef",
|
| 63 |
+
alert_types=[AlertType.HONEYPOT, AlertType.RUG_PULL],
|
| 64 |
+
)
|
| 65 |
+
sub = await service.create_subscription(req)
|
| 66 |
+
assert [AlertType(t) for t in sub.alert_types] == [AlertType.HONEYPOT, AlertType.RUG_PULL]
|
| 67 |
+
fake_repo.create.assert_awaited_once()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
async def test_list_filters_by_owner(service, fake_repo):
|
| 71 |
+
fake_repo.list_all = AsyncMock(return_value=[
|
| 72 |
+
AlertSubscription(id="alert:1", token_address="0xa", owner_id="user-1"),
|
| 73 |
+
AlertSubscription(id="alert:2", token_address="0xb", owner_id="user-2"),
|
| 74 |
+
AlertSubscription(id="alert:3", token_address="0xc", owner_id=None),
|
| 75 |
+
])
|
| 76 |
+
result = await service.list_subscriptions(owner_id="user-1")
|
| 77 |
+
assert len(result) == 1
|
| 78 |
+
assert result[0].id == "alert:1"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def test_cancel_unauthorized_returns_false(service, fake_repo):
|
| 82 |
+
fake_repo.get = AsyncMock(return_value=AlertSubscription(
|
| 83 |
+
id="alert:1", token_address="0xa", owner_id="user-1",
|
| 84 |
+
))
|
| 85 |
+
ok = await service.cancel_subscription("alert:1", owner_id="user-2")
|
| 86 |
+
assert ok is False
|
| 87 |
+
fake_repo.delete.assert_not_awaited()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
async def test_cancel_owner_succeeds(service, fake_repo):
|
| 91 |
+
fake_repo.get = AsyncMock(return_value=AlertSubscription(
|
| 92 |
+
id="alert:1", token_address="0xa", owner_id="user-1",
|
| 93 |
+
))
|
| 94 |
+
fake_repo.delete = AsyncMock(return_value=True)
|
| 95 |
+
ok = await service.cancel_subscription("alert:1", owner_id="user-1")
|
| 96 |
+
assert ok is True
|
| 97 |
+
fake_repo.delete.assert_awaited_once_with("alert:1")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
async def test_fire_event_broadcasts_to_matching_subs(service, fake_repo, fake_broadcaster):
|
| 101 |
+
fake_repo.list_by_token = AsyncMock(return_value=[
|
| 102 |
+
AlertSubscription(
|
| 103 |
+
id="alert:1", token_address="0xa",
|
| 104 |
+
alert_types=[AlertType.HONEYPOT, AlertType.RUG_PULL],
|
| 105 |
+
active=True,
|
| 106 |
+
),
|
| 107 |
+
AlertSubscription(
|
| 108 |
+
id="alert:2", token_address="0xa",
|
| 109 |
+
alert_types=[AlertType.WHALE_MOVEMENT], # doesn't match
|
| 110 |
+
active=True,
|
| 111 |
+
),
|
| 112 |
+
AlertSubscription(
|
| 113 |
+
id="alert:3", token_address="0xa",
|
| 114 |
+
alert_types=[AlertType.HONEYPOT],
|
| 115 |
+
active=False, # inactive
|
| 116 |
+
),
|
| 117 |
+
])
|
| 118 |
+
events = await service.fire_event("0xa", AlertType.HONEYPOT, {"score": 0.9})
|
| 119 |
+
assert len(events) == 1
|
| 120 |
+
assert events[0].subscription_id == "alert:1"
|
| 121 |
+
assert events[0].severity == "critical" # HONEYPOT is critical
|
| 122 |
+
fake_broadcaster.broadcast_event.assert_awaited_once()
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
async def test_severity_mapping():
|
| 126 |
+
from app.domain.alerts.service import _severity_for
|
| 127 |
+
assert _severity_for(AlertType.RUG_PULL) == "critical"
|
| 128 |
+
assert _severity_for(AlertType.LIQUIDITY_REMOVE) == "warning"
|
| 129 |
+
assert _severity_for(AlertType.MINT) == "info"
|