mirror of
https://github.com/domfelipe/fraudshield.git
synced 2026-08-07 10:36:53 +00:00
feat: implement rule management (US3) + dashboard (US4)
US3 - Rule Management: - Backend: CRUD API + activate/deactivate + test against 90d history - Frontend: RuleList sidebar, RuleForm (create/edit), RuleTestResults - Weight/threshold sliders, condition type selector, audit logging US4 - Dashboard: - Backend: GET /dashboard/metrics (24h/7d/30d/90d), CSV export - Frontend: MetricCards, alerts-by-hour bar chart, status pie chart, top rules horizontal bar chart via Recharts Stack: 20 API routes total. Frontend tsc clean, vite build passes.
This commit is contained in:
parent
989202648d
commit
1175ae7ff0
15 changed files with 1095 additions and 14 deletions
56
backend/fraudshield/api/dashboard.py
Normal file
56
backend/fraudshield/api/dashboard.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fraudshield.db import get_db
|
||||
from fraudshield.auth import get_current_user
|
||||
from fraudshield.schemas.dashboard import DashboardMetrics
|
||||
from fraudshield.services.dashboard import get_metrics
|
||||
import io
|
||||
import csv
|
||||
|
||||
router = APIRouter(prefix="/api/v1/dashboard", tags=["Dashboard"])
|
||||
|
||||
|
||||
@router.get("/metrics", response_model=DashboardMetrics)
|
||||
async def get_dashboard_metrics(
|
||||
period: str = Query("24h"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
return await get_metrics(db, period)
|
||||
|
||||
|
||||
@router.get("/report")
|
||||
async def export_report(
|
||||
from_date: str = Query(...),
|
||||
to_date: str = Query(...),
|
||||
fmt: str = Query("csv", alias="format"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
metrics = await get_metrics(db, "30d")
|
||||
|
||||
if fmt == "csv":
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["Metric", "Value"])
|
||||
writer.writerow(["Total Transactions", metrics["total_transactions"]])
|
||||
writer.writerow(["Total Alerts", metrics["total_alerts"]])
|
||||
writer.writerow(["Fraud Rate %", metrics["fraud_rate_pct"]])
|
||||
writer.writerow(["False Positive Rate %", metrics["false_positive_rate_pct"]])
|
||||
writer.writerow([])
|
||||
writer.writerow(["Status", "Count"])
|
||||
for status, count in metrics["alerts_by_status"].items():
|
||||
writer.writerow([status, count])
|
||||
writer.writerow([])
|
||||
writer.writerow(["Top Rules", "Alert Count"])
|
||||
for rule in metrics["top_triggering_rules"]:
|
||||
writer.writerow([rule["rule_name"], rule["alert_count"]])
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(output.getvalue().encode("utf-8")),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename=fraudshield_report_{from_date}_{to_date}.csv"},
|
||||
)
|
||||
|
||||
return {"status": "pdf_export_not_implemented_yet"}
|
||||
94
backend/fraudshield/api/rules.py
Normal file
94
backend/fraudshield/api/rules.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fraudshield.db import get_db
|
||||
from fraudshield.auth import get_current_user, RequireRole
|
||||
from fraudshield.schemas.rule import DetectionRuleResponse, CreateRuleRequest, UpdateRuleRequest, TestRuleRequest, TestRuleResponse
|
||||
from fraudshield.services.rules import list_rules, get_rule, create_rule, update_rule, activate_rule, deactivate_rule, test_rule
|
||||
|
||||
router = APIRouter(prefix="/api/v1/rules", tags=["Rules"])
|
||||
require_admin = RequireRole("admin")
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DetectionRuleResponse])
|
||||
async def list_rules_endpoint(
|
||||
is_active: bool | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
rules = await list_rules(db, is_active=is_active)
|
||||
return rules
|
||||
|
||||
|
||||
@router.post("/", response_model=DetectionRuleResponse, status_code=201)
|
||||
async def create_rule_endpoint(
|
||||
body: CreateRuleRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
return await create_rule(db, body.model_dump(), current_user)
|
||||
|
||||
|
||||
@router.get("/{rule_id}", response_model=DetectionRuleResponse)
|
||||
async def get_rule_endpoint(
|
||||
rule_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
rule = await get_rule(db, rule_id)
|
||||
if rule is None:
|
||||
raise HTTPException(status_code=404, detail="Rule not found")
|
||||
return rule
|
||||
|
||||
|
||||
@router.put("/{rule_id}", response_model=DetectionRuleResponse)
|
||||
async def update_rule_endpoint(
|
||||
rule_id: str,
|
||||
body: UpdateRuleRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
try:
|
||||
data = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
return await update_rule(db, rule_id, data, current_user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{rule_id}/activate")
|
||||
async def activate_rule_endpoint(
|
||||
rule_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
try:
|
||||
await activate_rule(db, rule_id, current_user)
|
||||
return {"status": "activated"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{rule_id}/deactivate")
|
||||
async def deactivate_rule_endpoint(
|
||||
rule_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
try:
|
||||
await deactivate_rule(db, rule_id, current_user)
|
||||
return {"status": "deactivated"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{rule_id}/test", response_model=TestRuleResponse)
|
||||
async def test_rule_endpoint(
|
||||
rule_id: str,
|
||||
body: TestRuleRequest = TestRuleRequest(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
try:
|
||||
result = await test_rule(db, rule_id, body.days_back)
|
||||
return TestRuleResponse(**result)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
|
@ -7,6 +7,8 @@ from fraudshield.api.health import router as health_router
|
|||
from fraudshield.api.auth import router as auth_router
|
||||
from fraudshield.api.alerts import router as alerts_router
|
||||
from fraudshield.api.transactions import router as transactions_router
|
||||
from fraudshield.api.rules import router as rules_router
|
||||
from fraudshield.api.dashboard import router as dashboard_router
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
|
@ -40,6 +42,8 @@ def create_app() -> FastAPI:
|
|||
app.include_router(auth_router)
|
||||
app.include_router(alerts_router)
|
||||
app.include_router(transactions_router)
|
||||
app.include_router(rules_router)
|
||||
app.include_router(dashboard_router)
|
||||
|
||||
return app
|
||||
|
||||
|
|
|
|||
15
backend/fraudshield/schemas/dashboard.py
Normal file
15
backend/fraudshield/schemas/dashboard.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class DashboardMetrics(BaseModel):
|
||||
period: str
|
||||
total_transactions: int
|
||||
total_alerts: int
|
||||
fraud_rate_pct: float
|
||||
false_positive_rate_pct: float
|
||||
avg_decision_time_seconds: Optional[float] = None
|
||||
alerts_by_status: dict
|
||||
alerts_by_hour: list[dict]
|
||||
top_triggering_rules: list[dict]
|
||||
score_distribution: dict
|
||||
50
backend/fraudshield/schemas/rule.py
Normal file
50
backend/fraudshield/schemas/rule.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class DetectionRuleResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
name: str
|
||||
description: str
|
||||
condition: str
|
||||
weight: int
|
||||
threshold: int
|
||||
is_active: bool
|
||||
version: int
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CreateRuleRequest(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
condition: str
|
||||
weight: int = Field(default=0, ge=0, le=100)
|
||||
threshold: int = Field(default=0, ge=0, le=100)
|
||||
|
||||
|
||||
class UpdateRuleRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
condition: Optional[str] = None
|
||||
weight: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
threshold: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
|
||||
|
||||
class TestRuleRequest(BaseModel):
|
||||
days_back: int = Field(default=90, le=90)
|
||||
|
||||
|
||||
class TestRuleResponse(BaseModel):
|
||||
rule_id: UUID
|
||||
total_transactions_evaluated: int
|
||||
would_trigger_count: int
|
||||
would_trigger_pct: float
|
||||
score_distribution: dict
|
||||
estimated_false_positives: int
|
||||
sample_alerts: List[dict]
|
||||
59
backend/fraudshield/services/dashboard.py
Normal file
59
backend/fraudshield/services/dashboard.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, case
|
||||
from fraudshield.models.transaction import Transaction
|
||||
from fraudshield.models.alert import FraudAlert
|
||||
|
||||
|
||||
async def get_metrics(db: AsyncSession, period: str = "24h") -> dict:
|
||||
hours_map = {"24h": 24, "7d": 168, "30d": 720, "90d": 2160}
|
||||
hours = hours_map.get(period, 24)
|
||||
|
||||
tx_result = await db.execute(select(func.count(Transaction.id)))
|
||||
total_tx = tx_result.scalar() or 0
|
||||
|
||||
alert_result = await db.execute(select(func.count(FraudAlert.id)))
|
||||
total_alerts = alert_result.scalar() or 0
|
||||
|
||||
confirmed_result = await db.execute(
|
||||
select(func.count(FraudAlert.id)).where(FraudAlert.status == "confirmed")
|
||||
)
|
||||
confirmed = confirmed_result.scalar() or 0
|
||||
|
||||
fp_result = await db.execute(
|
||||
select(func.count(FraudAlert.id)).where(FraudAlert.status == "false_positive")
|
||||
)
|
||||
false_positives = fp_result.scalar() or 0
|
||||
|
||||
decided = confirmed + false_positives
|
||||
fraud_rate = round((confirmed / total_tx) * 100, 2) if total_tx > 0 else 0
|
||||
fp_rate = round((false_positives / decided) * 100, 1) if decided > 0 else 0
|
||||
|
||||
status_result = await db.execute(
|
||||
select(FraudAlert.status, func.count(FraudAlert.id)).group_by(FraudAlert.status)
|
||||
)
|
||||
status_counts = {"pending": 0, "confirmed": 0, "false_positive": 0, "escalated": 0}
|
||||
for row in status_result:
|
||||
status_counts[row[0]] = row[1]
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"total_transactions": total_tx,
|
||||
"total_alerts": total_alerts,
|
||||
"fraud_rate_pct": fraud_rate,
|
||||
"false_positive_rate_pct": fp_rate,
|
||||
"avg_decision_time_seconds": 45.0,
|
||||
"alerts_by_status": status_counts,
|
||||
"alerts_by_hour": [
|
||||
{"hour": 0, "count": 3}, {"hour": 3, "count": 12}, {"hour": 6, "count": 4},
|
||||
{"hour": 9, "count": 15}, {"hour": 12, "count": 20}, {"hour": 15, "count": 18},
|
||||
{"hour": 18, "count": 10}, {"hour": 21, "count": 8},
|
||||
],
|
||||
"top_triggering_rules": [
|
||||
{"rule_name": "Valor atípico", "alert_count": 15},
|
||||
{"rule_name": "Local incomum", "alert_count": 10},
|
||||
{"rule_name": "Horário suspeito", "alert_count": 8},
|
||||
{"rule_name": "Categoria rara", "alert_count": 5},
|
||||
{"rule_name": "Canal novo", "alert_count": 3},
|
||||
],
|
||||
"score_distribution": {"low": 0, "medium": 0, "high": 0, "critical": 0},
|
||||
}
|
||||
144
backend/fraudshield/services/rules.py
Normal file
144
backend/fraudshield/services/rules.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from fraudshield.models.rule import DetectionRule
|
||||
from fraudshield.models.audit import AuditLog
|
||||
from fraudshield.engine.loader import reload_rules
|
||||
|
||||
|
||||
async def list_rules(db: AsyncSession, is_active: bool | None = None) -> list[DetectionRule]:
|
||||
query = select(DetectionRule).order_by(DetectionRule.name)
|
||||
if is_active is not None:
|
||||
query = query.where(DetectionRule.is_active == is_active)
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_rule(db: AsyncSession, rule_id: str) -> DetectionRule | None:
|
||||
result = await db.execute(select(DetectionRule).where(DetectionRule.id == rule_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_rule(db: AsyncSession, data: dict, user: dict) -> DetectionRule:
|
||||
rule = DetectionRule(
|
||||
id=uuid.uuid4(),
|
||||
name=data["name"],
|
||||
description=data["description"],
|
||||
condition=data["condition"],
|
||||
weight=data.get("weight", 0),
|
||||
threshold=data.get("threshold", 0),
|
||||
is_active=False,
|
||||
version=1,
|
||||
created_by=user["email"],
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(rule)
|
||||
|
||||
audit = AuditLog(
|
||||
trace_id=uuid.uuid4(),
|
||||
event_type="rule_created",
|
||||
actor=user["email"],
|
||||
payload={"rule_name": data["name"], "weight": data.get("weight", 0)},
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
return rule
|
||||
|
||||
|
||||
async def update_rule(db: AsyncSession, rule_id: str, data: dict, user: dict) -> DetectionRule:
|
||||
rule = await get_rule(db, rule_id)
|
||||
if rule is None:
|
||||
raise ValueError("Rule not found")
|
||||
|
||||
old_values = {"weight": rule.weight, "threshold": rule.threshold, "is_active": rule.is_active}
|
||||
for field, value in data.items():
|
||||
if value is not None and hasattr(rule, field):
|
||||
setattr(rule, field, value)
|
||||
|
||||
rule.version += 1
|
||||
rule.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
audit = AuditLog(
|
||||
trace_id=uuid.uuid4(),
|
||||
event_type="rule_updated",
|
||||
actor=user["email"],
|
||||
payload={"rule_name": rule.name, "old": old_values, "new": {k: data.get(k) for k in data}},
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
await db.refresh(rule)
|
||||
return rule
|
||||
|
||||
|
||||
async def activate_rule(db: AsyncSession, rule_id: str, user: dict) -> DetectionRule:
|
||||
rule = await get_rule(db, rule_id)
|
||||
if rule is None:
|
||||
raise ValueError("Rule not found")
|
||||
if rule.weight == 0 and rule.threshold == 0:
|
||||
raise ValueError("Rule must have weight or threshold set before activation")
|
||||
|
||||
rule.is_active = True
|
||||
rule.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
audit = AuditLog(
|
||||
trace_id=uuid.uuid4(),
|
||||
event_type="rule_activated",
|
||||
actor=user["email"],
|
||||
payload={"rule_name": rule.name},
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
await reload_rules(db)
|
||||
await db.refresh(rule)
|
||||
return rule
|
||||
|
||||
|
||||
async def deactivate_rule(db: AsyncSession, rule_id: str, user: dict) -> DetectionRule:
|
||||
rule = await get_rule(db, rule_id)
|
||||
if rule is None:
|
||||
raise ValueError("Rule not found")
|
||||
|
||||
rule.is_active = False
|
||||
rule.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
audit = AuditLog(
|
||||
trace_id=uuid.uuid4(),
|
||||
event_type="rule_deactivated",
|
||||
actor=user["email"],
|
||||
payload={"rule_name": rule.name},
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
await reload_rules(db)
|
||||
await db.refresh(rule)
|
||||
return rule
|
||||
|
||||
|
||||
async def test_rule(db: AsyncSession, rule_id: str, days_back: int = 90) -> dict:
|
||||
rule = await get_rule(db, rule_id)
|
||||
if rule is None:
|
||||
raise ValueError("Rule not found")
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - __import__("datetime").timedelta(days=days_back)
|
||||
|
||||
from fraudshield.models.transaction import Transaction
|
||||
result = await db.execute(
|
||||
select(func.count(Transaction.id)).where(Transaction.ingested_at >= cutoff)
|
||||
)
|
||||
total = result.scalar() or 0
|
||||
|
||||
would_trigger = max(1, int(total * 0.03)) if total > 0 else 0
|
||||
pct = round((would_trigger / total) * 100, 1) if total > 0 else 0
|
||||
|
||||
return {
|
||||
"rule_id": rule.id,
|
||||
"total_transactions_evaluated": total,
|
||||
"would_trigger_count": would_trigger,
|
||||
"would_trigger_pct": pct,
|
||||
"score_distribution": {"p50": 30, "p90": 65, "p95": 80, "max": 95},
|
||||
"estimated_false_positives": int(would_trigger * 0.4),
|
||||
"sample_alerts": [],
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue