diff --git a/backend/fraudshield/api/dashboard.py b/backend/fraudshield/api/dashboard.py new file mode 100644 index 0000000..3e8ef2f --- /dev/null +++ b/backend/fraudshield/api/dashboard.py @@ -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"} diff --git a/backend/fraudshield/api/rules.py b/backend/fraudshield/api/rules.py new file mode 100644 index 0000000..1a13817 --- /dev/null +++ b/backend/fraudshield/api/rules.py @@ -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)) diff --git a/backend/fraudshield/main.py b/backend/fraudshield/main.py index eeecc6f..9d7ea87 100644 --- a/backend/fraudshield/main.py +++ b/backend/fraudshield/main.py @@ -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 diff --git a/backend/fraudshield/schemas/dashboard.py b/backend/fraudshield/schemas/dashboard.py new file mode 100644 index 0000000..c2857e4 --- /dev/null +++ b/backend/fraudshield/schemas/dashboard.py @@ -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 diff --git a/backend/fraudshield/schemas/rule.py b/backend/fraudshield/schemas/rule.py new file mode 100644 index 0000000..dfe9172 --- /dev/null +++ b/backend/fraudshield/schemas/rule.py @@ -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] diff --git a/backend/fraudshield/services/dashboard.py b/backend/fraudshield/services/dashboard.py new file mode 100644 index 0000000..1ead4dc --- /dev/null +++ b/backend/fraudshield/services/dashboard.py @@ -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}, + } diff --git a/backend/fraudshield/services/rules.py b/backend/fraudshield/services/rules.py new file mode 100644 index 0000000..a901f51 --- /dev/null +++ b/backend/fraudshield/services/rules.py @@ -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": [], + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9b03a62..55bfa64 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,18 +4,8 @@ import Shell from './components/layout/Shell'; import { ProtectedRoute, AdminRoute } from './components/layout/ProtectedRoute'; import LoginPage from './pages/LoginPage'; import AlertsPage from './pages/AlertsPage'; - -function Placeholder({ title }: { title: string }) { - return ( -
Em desenvolvimento
-{label}
+{value}
+ {sub &&{sub}
} +{rule.name}
+Peso: {rule.weight} | v{rule.version}
+Transações avaliadas
+{result.total_transactions_evaluated.toLocaleString()}
+Disparariam
+{result.would_trigger_count.toLocaleString()}
+% de transações
+{result.would_trigger_pct}%
+Est. falsos positivos
+{result.estimated_false_positives}
+Apenas administradores podem gerenciar regras
+{selectedRule.description}
+Peso no Score
+{selectedRule.weight}
+Threshold
+{selectedRule.threshold}
+Tipo
+{selectedRule.condition}
+Versão
+v{selectedRule.version}
+Criado por: {selectedRule.created_by}
+Criado em: {new Date(selectedRule.created_at).toLocaleString('pt-BR')}
+Atualizado em: {new Date(selectedRule.updated_at).toLocaleString('pt-BR')}
+Selecione uma regra ou crie uma nova
+