mirror of
https://github.com/domfelipe/fraudshield.git
synced 2026-08-07 08:36:53 +00:00
feat: implement FraudShield MVP — backend API + frontend alerts
Backend (Python 3.12/FastAPI): - 5 models: Transaction, DetectionRule, FraudAlert, AuditLog, RuleSnapshot - Rule engine: 4 rule types (amount, location, time, pattern) - 11 API routes: health, auth, transactions, alerts - Sync ingestion pipeline with explanation generation - JWT auth with 3 roles (analyst, admin, senior) - Seed script with 5 default rules + 50 synthetic transactions Frontend (React 19/TypeScript/Tailwind): - Auth: login page, JWT token management, role-based routing - Alerts: queue with filters, detail panel, decision workflow - Layout: responsive sidebar, top bar, protected routes - Full TypeScript, zero ts-ignore, Tailwind CSS Build: Backend imports clean, Frontend tsc --noEmit passes, vite build produces 296KB production bundle.
This commit is contained in:
parent
14f2616037
commit
989202648d
60 changed files with 6943 additions and 0 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
0
backend/fraudshield/api/__init__.py
Normal file
0
backend/fraudshield/api/__init__.py
Normal file
73
backend/fraudshield/api/alerts.py
Normal file
73
backend/fraudshield/api/alerts.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from fraudshield.db import get_db
|
||||||
|
from fraudshield.auth import get_current_user
|
||||||
|
from fraudshield.schemas.alert import AlertDetail, AlertDecisionRequest, AlertListResponse
|
||||||
|
from fraudshield.services.alerts import list_alerts, get_alert, decide_alert
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_alert(alert):
|
||||||
|
tx = alert.transaction
|
||||||
|
return AlertDetail(
|
||||||
|
id=alert.id,
|
||||||
|
transaction_id=alert.transaction_id,
|
||||||
|
score=alert.score,
|
||||||
|
triggered_rules=alert.triggered_rules or [],
|
||||||
|
explanation=alert.explanation or "",
|
||||||
|
status=alert.status,
|
||||||
|
assigned_to=alert.assigned_to,
|
||||||
|
decision=alert.decision,
|
||||||
|
decided_by=alert.decided_by,
|
||||||
|
decided_at=alert.decided_at,
|
||||||
|
justification=alert.justification,
|
||||||
|
created_at=alert.created_at,
|
||||||
|
amount=float(tx.amount) if tx else 0,
|
||||||
|
merchant_name=tx.merchant_name if tx else "",
|
||||||
|
merchant_category=tx.merchant_category if tx else "",
|
||||||
|
channel=tx.channel if tx else "",
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/alerts", tags=["Alerts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=AlertListResponse)
|
||||||
|
async def list_alerts_endpoint(
|
||||||
|
status: str | None = Query(None),
|
||||||
|
min_score: int | None = Query(None, ge=0, le=100),
|
||||||
|
from_date: datetime | None = Query(None),
|
||||||
|
to_date: datetime | None = Query(None),
|
||||||
|
triggered_rule: str | None = Query(None),
|
||||||
|
limit: int = Query(50, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return await list_alerts(db, status=status, min_score=min_score, from_date=from_date, to_date=to_date, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{alert_id}", response_model=AlertDetail)
|
||||||
|
async def get_alert_endpoint(
|
||||||
|
alert_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
alert = await get_alert(db, alert_id)
|
||||||
|
if alert is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert not found")
|
||||||
|
return _enrich_alert(alert)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{alert_id}/decide", response_model=AlertDetail)
|
||||||
|
async def decide_alert_endpoint(
|
||||||
|
alert_id: str,
|
||||||
|
body: AlertDecisionRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
alert = await decide_alert(db, alert_id, body.decision, current_user, body.justification)
|
||||||
|
await db.refresh(alert, ["transaction"])
|
||||||
|
return _enrich_alert(alert)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=409 if "already decided" in str(e) else 404, detail=str(e))
|
||||||
18
backend/fraudshield/api/auth.py
Normal file
18
backend/fraudshield/api/auth.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from fraudshield.auth import create_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token")
|
||||||
|
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||||
|
email = form_data.username
|
||||||
|
role = "analyst"
|
||||||
|
if "admin" in email:
|
||||||
|
role = "admin"
|
||||||
|
elif "senior" in email:
|
||||||
|
role = "senior"
|
||||||
|
|
||||||
|
token = create_token({"sub": email, "email": email, "role": role})
|
||||||
|
return {"access_token": token, "token_type": "bearer", "role": role}
|
||||||
14
backend/fraudshield/api/deps.py
Normal file
14
backend/fraudshield/api/deps.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from fastapi import Depends, HTTPException, status, Security
|
||||||
|
from fastapi.security import APIKeyHeader
|
||||||
|
from fraudshield.db import get_db
|
||||||
|
from fraudshield.auth import get_current_user
|
||||||
|
from fraudshield.config import settings
|
||||||
|
|
||||||
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_api_key(api_key: str = Security(api_key_header)) -> str:
|
||||||
|
if not api_key or api_key != settings.API_KEY:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid API key")
|
||||||
|
return api_key
|
||||||
13
backend/fraudshield/api/health.py
Normal file
13
backend/fraudshield/api/health.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(tags=["System"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
41
backend/fraudshield/api/transactions.py
Normal file
41
backend/fraudshield/api/transactions.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from fraudshield.db import get_db
|
||||||
|
from fraudshield.auth import get_current_user
|
||||||
|
from fraudshield.api.deps import get_api_key
|
||||||
|
from fraudshield.models.transaction import Transaction
|
||||||
|
from fraudshield.schemas.transaction import TransactionIngestRequest, TransactionAccepted, TransactionDetail
|
||||||
|
from fraudshield.services.ingestion import ingest_transaction
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/transactions", tags=["Transactions"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ingest", response_model=TransactionAccepted, status_code=202)
|
||||||
|
async def ingest_transaction_endpoint(
|
||||||
|
body: TransactionIngestRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
api_key: str = Depends(get_api_key),
|
||||||
|
):
|
||||||
|
result = await ingest_transaction(db, body)
|
||||||
|
return TransactionAccepted(
|
||||||
|
transaction_id=uuid.UUID(result["transaction_id"]),
|
||||||
|
status=result["status"],
|
||||||
|
trace_id=result["trace_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{transaction_id}", response_model=TransactionDetail)
|
||||||
|
async def get_transaction_endpoint(
|
||||||
|
transaction_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
query = select(Transaction).options(selectinload(Transaction.alert)).where(Transaction.id == transaction_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
tx = result.scalar_one_or_none()
|
||||||
|
if tx is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||||
|
return tx
|
||||||
43
backend/fraudshield/auth.py
Normal file
43
backend/fraudshield/auth.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from fraudshield.config import settings
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")
|
||||||
|
|
||||||
|
|
||||||
|
def create_token(data: dict) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||||
|
to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)})
|
||||||
|
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
|
||||||
|
sub: str = payload.get("sub")
|
||||||
|
if sub is None:
|
||||||
|
raise credentials_exception
|
||||||
|
return {"id": sub, "email": payload.get("email", ""), "role": payload.get("role", "analyst")}
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
|
||||||
|
class RequireRole:
|
||||||
|
def __init__(self, *roles: str):
|
||||||
|
self.roles = roles
|
||||||
|
|
||||||
|
async def __call__(self, current_user: dict = Depends(get_current_user)):
|
||||||
|
if current_user["role"] not in self.roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Role {current_user['role']} not authorized. Required: {self.roles}",
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
17
backend/fraudshield/config.py
Normal file
17
backend/fraudshield/config.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
|
|
||||||
|
DATABASE_URL: str = "postgresql+asyncpg://fraudshield:fraudshield_dev@localhost:5432/fraudshield"
|
||||||
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
JWT_SECRET: str = "dev-secret-change-in-production"
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
API_KEY: str = "dev-api-key"
|
||||||
|
ALERT_THRESHOLD: int = 70
|
||||||
|
CORS_ORIGINS: List[str] = ["http://localhost:5173"]
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
23
backend/fraudshield/db.py
Normal file
23
backend/fraudshield/db.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||||
|
from fraudshield.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with async_session() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
from fraudshield.models.base import Base
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
|
||||||
|
async def close_db():
|
||||||
|
await engine.dispose()
|
||||||
3
backend/fraudshield/engine/__init__.py
Normal file
3
backend/fraudshield/engine/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from fraudshield.engine.rules.base import BaseRule, RuleResult, RuleEngine
|
||||||
|
from fraudshield.engine.evaluator import evaluate_transaction
|
||||||
|
from fraudshield.engine.explainer import generate_composite_explanation
|
||||||
23
backend/fraudshield/engine/evaluator.py
Normal file
23
backend/fraudshield/engine/evaluator.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from fraudshield.engine.rules.base import BaseRule, RuleEngine
|
||||||
|
from fraudshield.engine.explainer import generate_composite_explanation
|
||||||
|
|
||||||
|
|
||||||
|
async def evaluate_transaction(transaction, rules: list[BaseRule], context: dict) -> dict:
|
||||||
|
engine = RuleEngine(rules)
|
||||||
|
result = engine.evaluate_all(transaction, context)
|
||||||
|
|
||||||
|
triggered = result["triggered_rules"]
|
||||||
|
composite_score = result["composite_score"]
|
||||||
|
explanation = generate_composite_explanation(triggered, composite_score)
|
||||||
|
|
||||||
|
triggered_data = [
|
||||||
|
{
|
||||||
|
"rule_id": r.rule_id,
|
||||||
|
"rule_name": r.rule_name,
|
||||||
|
"score": r.score,
|
||||||
|
"explanation": r.explanation,
|
||||||
|
}
|
||||||
|
for r in triggered
|
||||||
|
]
|
||||||
|
|
||||||
|
return {"score": composite_score, "triggered_rules": triggered_data, "explanation": explanation}
|
||||||
13
backend/fraudshield/engine/explainer.py
Normal file
13
backend/fraudshield/engine/explainer.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from fraudshield.engine.rules.base import RuleResult
|
||||||
|
|
||||||
|
|
||||||
|
def generate_composite_explanation(triggered_rules: list[RuleResult], composite_score: int) -> str:
|
||||||
|
if not triggered_rules:
|
||||||
|
return "Nenhuma regra de fraude foi acionada para esta transação."
|
||||||
|
|
||||||
|
n = len(triggered_rules)
|
||||||
|
parts = [f"Transação sinalizada por {n} regra(s):"]
|
||||||
|
for r in triggered_rules:
|
||||||
|
parts.append(f"• {r.rule_name} (+{r.score}): {r.explanation}")
|
||||||
|
parts.append(f"Score composto: {composite_score}/100.")
|
||||||
|
return "\n".join(parts)
|
||||||
57
backend/fraudshield/engine/loader.py
Normal file
57
backend/fraudshield/engine/loader.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from fraudshield.models.rule import DetectionRule, RuleSnapshot
|
||||||
|
from fraudshield.engine.rules.amount import AmountAnomalyRule
|
||||||
|
from fraudshield.engine.rules.location import LocationAnomalyRule
|
||||||
|
from fraudshield.engine.rules.time import TimeBasedRule
|
||||||
|
from fraudshield.engine.rules.pattern import PatternAnomalyRule
|
||||||
|
from fraudshield.engine.rules.base import BaseRule, RuleEngine
|
||||||
|
import json
|
||||||
|
|
||||||
|
RULE_CLASS_MAP = {
|
||||||
|
"amount": AmountAnomalyRule,
|
||||||
|
"location": LocationAnomalyRule,
|
||||||
|
"time": TimeBasedRule,
|
||||||
|
"pattern": PatternAnomalyRule,
|
||||||
|
}
|
||||||
|
|
||||||
|
_active_rules_cache: list[BaseRule] | None = None
|
||||||
|
_ruleset_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def load_active_rules(db: AsyncSession) -> list[BaseRule]:
|
||||||
|
global _active_rules_cache, _ruleset_version
|
||||||
|
|
||||||
|
result = await db.execute(select(DetectionRule).where(DetectionRule.is_active == True))
|
||||||
|
db_rules = result.scalars().all()
|
||||||
|
|
||||||
|
rules = []
|
||||||
|
for db_rule in db_rules:
|
||||||
|
rule_type = db_rule.condition.split("_")[0] if "_" in db_rule.condition else "amount"
|
||||||
|
rule_cls = RULE_CLASS_MAP.get(rule_type, AmountAnomalyRule)
|
||||||
|
rule = rule_cls(rule_id=str(db_rule.id), name=db_rule.name, weight=db_rule.weight)
|
||||||
|
rules.append(rule)
|
||||||
|
|
||||||
|
_active_rules_cache = rules
|
||||||
|
_ruleset_version = f"v{len(db_rules)}"
|
||||||
|
return rules
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_rules() -> list[BaseRule]:
|
||||||
|
return _active_rules_cache or []
|
||||||
|
|
||||||
|
|
||||||
|
def get_ruleset_version() -> str:
|
||||||
|
return _ruleset_version or "default"
|
||||||
|
|
||||||
|
|
||||||
|
async def reload_rules(db: AsyncSession):
|
||||||
|
global _active_rules_cache, _ruleset_version
|
||||||
|
await load_active_rules(db)
|
||||||
|
|
||||||
|
rules_data = [
|
||||||
|
{"id": r.rule_id, "name": r.name, "weight": r.weight} for r in (_active_rules_cache or [])
|
||||||
|
]
|
||||||
|
snapshot = RuleSnapshot(version=_ruleset_version or "v1", rules_json=json.dumps(rules_data))
|
||||||
|
db.add(snapshot)
|
||||||
|
await db.commit()
|
||||||
1
backend/fraudshield/engine/rules/__init__.py
Normal file
1
backend/fraudshield/engine/rules/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from fraudshield.engine.rules.base import BaseRule, RuleResult
|
||||||
25
backend/fraudshield/engine/rules/amount.py
Normal file
25
backend/fraudshield/engine/rules/amount.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from fraudshield.engine.rules.base import BaseRule, make_rule_result, RuleResult
|
||||||
|
|
||||||
|
|
||||||
|
class AmountAnomalyRule(BaseRule):
|
||||||
|
def evaluate(self, transaction, context: dict) -> RuleResult:
|
||||||
|
profile = context.get("customer_profile", {})
|
||||||
|
avg = profile.get("avg_amount_30d")
|
||||||
|
|
||||||
|
if avg is None or float(avg) <= 0:
|
||||||
|
return make_rule_result(self.rule_id, self.name, 0, False, "Sem histórico do cliente para comparação")
|
||||||
|
|
||||||
|
tx_amount = float(transaction.amount)
|
||||||
|
ratio = tx_amount / float(avg)
|
||||||
|
|
||||||
|
if ratio > 3:
|
||||||
|
score = min(int(ratio * self.weight / 10), self.weight)
|
||||||
|
return make_rule_result(
|
||||||
|
self.rule_id,
|
||||||
|
self.name,
|
||||||
|
score,
|
||||||
|
True,
|
||||||
|
f"Valor de R$ {tx_amount:,.2f} é {ratio:.1f}x acima da média de R$ {float(avg):,.2f} do cliente nos últimos 30 dias",
|
||||||
|
)
|
||||||
|
|
||||||
|
return make_rule_result(self.rule_id, self.name, 0, False, "Valor dentro do padrão do cliente")
|
||||||
34
backend/fraudshield/engine/rules/base.py
Normal file
34
backend/fraudshield/engine/rules/base.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
from collections import namedtuple
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
RuleResult = namedtuple("RuleResult", ["rule_id", "rule_name", "score", "triggered", "explanation"])
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRule(ABC):
|
||||||
|
def __init__(self, rule_id: str, name: str, weight: int):
|
||||||
|
self.rule_id = rule_id
|
||||||
|
self.name = name
|
||||||
|
self.weight = weight
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def evaluate(self, transaction, context: dict) -> RuleResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def make_rule_result(rule_id: str, rule_name: str, score: int, triggered: bool, explanation: str) -> RuleResult:
|
||||||
|
return RuleResult(rule_id=rule_id, rule_name=rule_name, score=score, triggered=triggered, explanation=explanation)
|
||||||
|
|
||||||
|
|
||||||
|
class RuleEngine:
|
||||||
|
def __init__(self, rules: list[BaseRule]):
|
||||||
|
self.rules = rules
|
||||||
|
|
||||||
|
def evaluate_all(self, transaction, context: dict) -> dict:
|
||||||
|
triggered = []
|
||||||
|
for rule in self.rules:
|
||||||
|
result = rule.evaluate(transaction, context)
|
||||||
|
if result.triggered:
|
||||||
|
triggered.append(result)
|
||||||
|
|
||||||
|
composite_score = min(sum(r.score for r in triggered), 100)
|
||||||
|
return {"triggered_rules": triggered, "composite_score": composite_score}
|
||||||
36
backend/fraudshield/engine/rules/location.py
Normal file
36
backend/fraudshield/engine/rules/location.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import math
|
||||||
|
from fraudshield.engine.rules.base import BaseRule, make_rule_result, RuleResult
|
||||||
|
|
||||||
|
|
||||||
|
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
|
R = 6371
|
||||||
|
dlat = math.radians(lat2 - lat1)
|
||||||
|
dlon = math.radians(lon2 - lon1)
|
||||||
|
a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
|
||||||
|
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||||
|
|
||||||
|
|
||||||
|
class LocationAnomalyRule(BaseRule):
|
||||||
|
def evaluate(self, transaction, context: dict) -> RuleResult:
|
||||||
|
if transaction.latitude is None or transaction.longitude is None:
|
||||||
|
return make_rule_result(self.rule_id, self.name, 0, False, "Dados de localização indisponíveis")
|
||||||
|
|
||||||
|
profile = context.get("customer_profile", {})
|
||||||
|
home_lat = profile.get("home_latitude")
|
||||||
|
home_lon = profile.get("home_longitude")
|
||||||
|
|
||||||
|
if home_lat is None or home_lon is None:
|
||||||
|
return make_rule_result(self.rule_id, self.name, 0, False, "Sem localização de referência do cliente")
|
||||||
|
|
||||||
|
distance = haversine(float(transaction.latitude), float(transaction.longitude), float(home_lat), float(home_lon))
|
||||||
|
|
||||||
|
if distance > 100:
|
||||||
|
return make_rule_result(
|
||||||
|
self.rule_id,
|
||||||
|
self.name,
|
||||||
|
self.weight,
|
||||||
|
True,
|
||||||
|
f"Transação realizada a {distance:.0f} km da região habitual do cliente",
|
||||||
|
)
|
||||||
|
|
||||||
|
return make_rule_result(self.rule_id, self.name, 0, False, "Localização dentro do padrão do cliente")
|
||||||
25
backend/fraudshield/engine/rules/pattern.py
Normal file
25
backend/fraudshield/engine/rules/pattern.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from fraudshield.engine.rules.base import BaseRule, make_rule_result, RuleResult
|
||||||
|
|
||||||
|
|
||||||
|
class PatternAnomalyRule(BaseRule):
|
||||||
|
def evaluate(self, transaction, context: dict) -> RuleResult:
|
||||||
|
profile = context.get("customer_profile", {})
|
||||||
|
common_categories = profile.get("common_categories", [])
|
||||||
|
common_channels = profile.get("common_channels", [])
|
||||||
|
|
||||||
|
triggered = False
|
||||||
|
reasons = []
|
||||||
|
|
||||||
|
if common_categories and transaction.merchant_category not in common_categories:
|
||||||
|
triggered = True
|
||||||
|
reasons.append(f"Categoria '{transaction.merchant_category}' não faz parte do padrão de consumo habitual do cliente")
|
||||||
|
|
||||||
|
if common_channels and transaction.channel not in common_channels:
|
||||||
|
triggered = True
|
||||||
|
reasons.append(f"Canal '{transaction.channel}' não utilizado habitualmente pelo cliente")
|
||||||
|
|
||||||
|
if triggered:
|
||||||
|
score = min(self.weight, 40)
|
||||||
|
return make_rule_result(self.rule_id, self.name, score, True, "; ".join(reasons))
|
||||||
|
|
||||||
|
return make_rule_result(self.rule_id, self.name, 0, False, "Padrão de consumo dentro do habitual")
|
||||||
38
backend/fraudshield/engine/rules/time.py
Normal file
38
backend/fraudshield/engine/rules/time.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
from datetime import timezone
|
||||||
|
from fraudshield.engine.rules.base import BaseRule, make_rule_result, RuleResult
|
||||||
|
|
||||||
|
|
||||||
|
class TimeBasedRule(BaseRule):
|
||||||
|
def evaluate(self, transaction, context: dict) -> RuleResult:
|
||||||
|
if transaction.transaction_at is None:
|
||||||
|
return make_rule_result(self.rule_id, self.name, 0, False, "Timestamp da transação indisponível")
|
||||||
|
|
||||||
|
if transaction.transaction_at.tzinfo is None:
|
||||||
|
tx_hour = transaction.transaction_at.hour
|
||||||
|
else:
|
||||||
|
tx_hour = transaction.transaction_at.astimezone(timezone.utc).hour
|
||||||
|
|
||||||
|
local_hour = (tx_hour - 3) % 24
|
||||||
|
|
||||||
|
is_night = 0 <= local_hour <= 5
|
||||||
|
is_high_amount = float(transaction.amount) > 500
|
||||||
|
|
||||||
|
profile = context.get("customer_profile", {})
|
||||||
|
night_pct = profile.get("night_transactions_pct", 50)
|
||||||
|
|
||||||
|
triggered = False
|
||||||
|
reasons = []
|
||||||
|
|
||||||
|
if is_night and is_high_amount:
|
||||||
|
triggered = True
|
||||||
|
reasons.append(f"Transação de alto valor (R$ {float(transaction.amount):,.2f}) em horário noturno ({local_hour}h)")
|
||||||
|
|
||||||
|
if is_night and float(night_pct) < 10:
|
||||||
|
triggered = True
|
||||||
|
reasons.append(f"Transação noturna ({local_hour}h) atípica para este cliente (apenas {float(night_pct):.0f}% das transações são noturnas)")
|
||||||
|
|
||||||
|
if triggered:
|
||||||
|
score = min(self.weight, 50)
|
||||||
|
return make_rule_result(self.rule_id, self.name, score, True, "; ".join(reasons))
|
||||||
|
|
||||||
|
return make_rule_result(self.rule_id, self.name, 0, False, "Horário da transação dentro do padrão")
|
||||||
47
backend/fraudshield/main.py
Normal file
47
backend/fraudshield/main.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fraudshield.config import settings
|
||||||
|
from fraudshield.db import init_db, close_db
|
||||||
|
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
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
logger.info("Starting FraudShield...")
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
logger.info("Shutting down FraudShield...")
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(
|
||||||
|
title="FraudShield API",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.CORS_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(health_router)
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(alerts_router)
|
||||||
|
app.include_router(transactions_router)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
5
backend/fraudshield/models/__init__.py
Normal file
5
backend/fraudshield/models/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from fraudshield.models.base import Base
|
||||||
|
from fraudshield.models.transaction import Transaction
|
||||||
|
from fraudshield.models.rule import DetectionRule, RuleSnapshot
|
||||||
|
from fraudshield.models.alert import FraudAlert
|
||||||
|
from fraudshield.models.audit import AuditLog
|
||||||
29
backend/fraudshield/models/alert.py
Normal file
29
backend/fraudshield/models/alert.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
from sqlalchemy import Column, String, Integer, Text, DateTime, ForeignKey, Index, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from fraudshield.models.base import Base, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class FraudAlert(Base, UUIDPrimaryKeyMixin):
|
||||||
|
__tablename__ = "fraud_alerts"
|
||||||
|
|
||||||
|
transaction_id = Column(UUID(as_uuid=True), ForeignKey("transactions.id"), nullable=False)
|
||||||
|
score = Column(Integer, nullable=False)
|
||||||
|
triggered_rules = Column(JSONB, nullable=False)
|
||||||
|
explanation = Column(Text, nullable=False)
|
||||||
|
status = Column(String(20), nullable=False, default="pending")
|
||||||
|
assigned_to = Column(String(255), nullable=True)
|
||||||
|
decision = Column(String(20), nullable=True)
|
||||||
|
decided_by = Column(String(255), nullable=True)
|
||||||
|
decided_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
justification = Column(Text, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
ruleset_version = Column(String(20), nullable=True)
|
||||||
|
|
||||||
|
transaction = relationship("Transaction", back_populates="alert")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_alerts_status", "status"),
|
||||||
|
Index("idx_alerts_created_at", "created_at"),
|
||||||
|
Index("idx_alerts_transaction_id", "transaction_id"),
|
||||||
|
)
|
||||||
21
backend/fraudshield/models/audit.py
Normal file
21
backend/fraudshield/models/audit.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy import Column, String, DateTime, Index, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
from fraudshield.models.base import Base, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base, UUIDPrimaryKeyMixin):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
trace_id = Column(UUID(as_uuid=True), nullable=False, default=uuid.uuid4)
|
||||||
|
event_type = Column(String(50), nullable=False)
|
||||||
|
event_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
actor = Column(String(255), nullable=True)
|
||||||
|
payload = Column(JSONB, nullable=False)
|
||||||
|
ruleset_version = Column(String(20), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_audit_trace_id", "trace_id"),
|
||||||
|
Index("idx_audit_event_type", "event_type"),
|
||||||
|
Index("idx_audit_event_at", "event_at"),
|
||||||
|
)
|
||||||
18
backend/fraudshield/models/base.py
Normal file
18
backend/fraudshield/models/base.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Column, DateTime, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDPrimaryKeyMixin:
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at = Column(DateTime(timezone=True), default=func.now(), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), default=func.now(), server_default=func.now(), onupdate=func.now())
|
||||||
27
backend/fraudshield/models/rule.py
Normal file
27
backend/fraudshield/models/rule.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from sqlalchemy import Column, String, Integer, Boolean, Text, DateTime, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
from fraudshield.models.base import Base, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class DetectionRule(Base, UUIDPrimaryKeyMixin):
|
||||||
|
__tablename__ = "detection_rules"
|
||||||
|
|
||||||
|
name = Column(String(255), unique=True, nullable=False)
|
||||||
|
description = Column(Text, nullable=False)
|
||||||
|
condition = Column(Text, nullable=False)
|
||||||
|
weight = Column(Integer, nullable=False, default=0)
|
||||||
|
threshold = Column(Integer, nullable=False, default=0)
|
||||||
|
is_active = Column(Boolean, nullable=False, default=False)
|
||||||
|
version = Column(Integer, nullable=False, default=1)
|
||||||
|
created_by = Column(String(255), nullable=False, default="system")
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class RuleSnapshot(Base):
|
||||||
|
__tablename__ = "rule_snapshots"
|
||||||
|
|
||||||
|
version = Column(String(20), primary_key=True, nullable=False)
|
||||||
|
rules_json = Column(JSONB, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
created_by = Column(String(255), nullable=False, default="system")
|
||||||
28
backend/fraudshield/models/transaction.py
Normal file
28
backend/fraudshield/models/transaction.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from sqlalchemy import Column, String, Numeric, DateTime, Float, Index
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from fraudshield.models.base import Base, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(Base, UUIDPrimaryKeyMixin):
|
||||||
|
__tablename__ = "transactions"
|
||||||
|
|
||||||
|
external_id = Column(String(255), unique=True, nullable=False)
|
||||||
|
amount = Column(Numeric(15, 2), nullable=False)
|
||||||
|
merchant_name = Column(String(255), nullable=False)
|
||||||
|
merchant_category = Column(String(100), nullable=False)
|
||||||
|
latitude = Column(Float, nullable=True)
|
||||||
|
longitude = Column(Float, nullable=True)
|
||||||
|
customer_id = Column(String(128), nullable=False, index=True)
|
||||||
|
channel = Column(String(20), nullable=False)
|
||||||
|
transaction_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
ingested_at = Column(DateTime(timezone=True), server_default="now()", nullable=False)
|
||||||
|
ruleset_version = Column(String(20), nullable=True)
|
||||||
|
raw_payload = Column(JSONB, nullable=False)
|
||||||
|
|
||||||
|
alert = relationship("FraudAlert", back_populates="transaction", uselist=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_transactions_customer_id", "customer_id"),
|
||||||
|
Index("idx_transactions_transaction_at", "transaction_at"),
|
||||||
|
)
|
||||||
0
backend/fraudshield/schemas/__init__.py
Normal file
0
backend/fraudshield/schemas/__init__.py
Normal file
57
backend/fraudshield/schemas/alert.py
Normal file
57
backend/fraudshield/schemas/alert.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from typing import Optional, List, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TriggeredRule(BaseModel):
|
||||||
|
rule_id: str
|
||||||
|
rule_name: str
|
||||||
|
score: int
|
||||||
|
explanation: str
|
||||||
|
|
||||||
|
|
||||||
|
class AlertSummary(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
score: int
|
||||||
|
status: str
|
||||||
|
amount: float
|
||||||
|
merchant_name: str
|
||||||
|
top_rule: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDetail(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
transaction_id: UUID
|
||||||
|
score: int
|
||||||
|
triggered_rules: List[TriggeredRule]
|
||||||
|
explanation: str
|
||||||
|
status: str
|
||||||
|
assigned_to: Optional[str] = None
|
||||||
|
decision: Optional[str] = None
|
||||||
|
decided_by: Optional[str] = None
|
||||||
|
decided_at: Optional[datetime] = None
|
||||||
|
justification: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
amount: float = 0
|
||||||
|
merchant_name: str = ""
|
||||||
|
merchant_category: str = ""
|
||||||
|
channel: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class AlertDecisionRequest(BaseModel):
|
||||||
|
decision: Literal["confirmed", "false_positive", "escalated"]
|
||||||
|
justification: Optional[str] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertListResponse(BaseModel):
|
||||||
|
items: List[AlertSummary]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
40
backend/fraudshield/schemas/transaction.py
Normal file
40
backend/fraudshield/schemas/transaction.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
from typing import Optional, Literal
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from fraudshield.schemas.alert import AlertDetail
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionIngestRequest(BaseModel):
|
||||||
|
external_id: str
|
||||||
|
amount: float = Field(gt=0.01)
|
||||||
|
merchant_name: str
|
||||||
|
merchant_category: str
|
||||||
|
latitude: Optional[float] = None
|
||||||
|
longitude: Optional[float] = None
|
||||||
|
customer_id: str
|
||||||
|
channel: Literal["web", "mobile", "pos"]
|
||||||
|
transaction_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionAccepted(BaseModel):
|
||||||
|
transaction_id: UUID
|
||||||
|
status: str
|
||||||
|
trace_id: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionDetail(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
external_id: str
|
||||||
|
amount: float
|
||||||
|
merchant_name: str
|
||||||
|
merchant_category: str
|
||||||
|
latitude: Optional[float] = None
|
||||||
|
longitude: Optional[float] = None
|
||||||
|
customer_id: str
|
||||||
|
channel: str
|
||||||
|
transaction_at: datetime
|
||||||
|
ingested_at: datetime
|
||||||
|
alert: Optional[AlertDetail] = None
|
||||||
121
backend/fraudshield/seed.py
Normal file
121
backend/fraudshield/seed.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||||
|
from fraudshield.config import settings
|
||||||
|
from fraudshield.models.base import Base
|
||||||
|
from fraudshield.models.transaction import Transaction
|
||||||
|
from fraudshield.models.rule import DetectionRule
|
||||||
|
from fraudshield.models.alert import FraudAlert
|
||||||
|
from fraudshield.models.audit import AuditLog
|
||||||
|
from fraudshield.engine.loader import load_active_rules
|
||||||
|
from fraudshield.engine.evaluator import evaluate_transaction
|
||||||
|
from fraudshield.services.detection import get_customer_context
|
||||||
|
|
||||||
|
TEST_DATABASE_URL = settings.DATABASE_URL.replace("fraudshield", "fraudshield_test")
|
||||||
|
|
||||||
|
DEFAULT_RULES = [
|
||||||
|
{"name": "Valor atípico", "description": "Detecta transações com valor muito acima da média do cliente", "condition": "amount_rule", "weight": 30, "threshold": 0},
|
||||||
|
{"name": "Local incomum", "description": "Detecta transações em locais distantes do padrão do cliente", "condition": "location_rule", "weight": 25, "threshold": 0},
|
||||||
|
{"name": "Horário suspeito", "description": "Detecta transações noturnas de alto valor", "condition": "time_rule", "weight": 20, "threshold": 0},
|
||||||
|
{"name": "Categoria rara", "description": "Detecta transações em categorias não usuais do cliente", "condition": "pattern_rule", "weight": 15, "threshold": 0},
|
||||||
|
{"name": "Canal novo", "description": "Detecta transações em canais não habituais", "condition": "pattern_rule", "weight": 10, "threshold": 0},
|
||||||
|
]
|
||||||
|
|
||||||
|
SAMPLE_MERCHANTS = [
|
||||||
|
("Supermercado Bom Preço", "alimentação", -23.55, -46.63),
|
||||||
|
("Posto Shell", "transporte", -23.56, -46.64),
|
||||||
|
("Farmácia Saúde", "saúde", -23.54, -46.62),
|
||||||
|
("Livraria Cultura", "educação", -23.55, -46.63),
|
||||||
|
("Joalheria Luxo", "joalheria", -22.90, -43.20),
|
||||||
|
("Loja Importados", "eletrônicos", -22.91, -43.21),
|
||||||
|
("Hotel Premium SP", "hotelaria", -23.58, -46.68),
|
||||||
|
("Casa de Câmbio Rápido", "serviços financeiros", -23.57, -46.66),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
|
||||||
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async with async_session() as db:
|
||||||
|
rules = []
|
||||||
|
for rdata in DEFAULT_RULES:
|
||||||
|
rule = DetectionRule(
|
||||||
|
id=uuid.uuid4(), name=rdata["name"], description=rdata["description"],
|
||||||
|
condition=rdata["condition"], weight=rdata["weight"],
|
||||||
|
threshold=rdata["threshold"], is_active=True,
|
||||||
|
)
|
||||||
|
db.add(rule)
|
||||||
|
rules.append(rule)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
loaded_rules = await load_active_rules(db)
|
||||||
|
print(f"Seeded {len(loaded_rules)} active rules")
|
||||||
|
|
||||||
|
base_time = datetime.now(timezone.utc) - timedelta(days=7)
|
||||||
|
alert_count = 0
|
||||||
|
|
||||||
|
for i in range(50):
|
||||||
|
merchant_idx = i % len(SAMPLE_MERCHANTS)
|
||||||
|
name, cat, lat, lon = SAMPLE_MERCHANTS[merchant_idx]
|
||||||
|
|
||||||
|
is_suspicious = i < 20
|
||||||
|
amount = 15000.0 if is_suspicious and i < 10 else (5000.0 if is_suspicious else 150.0)
|
||||||
|
tx_lat = -22.90 if is_suspicious and i < 5 else lat
|
||||||
|
tx_lon = -43.20 if is_suspicious and i < 5 else lon
|
||||||
|
tx_hour = 3 if is_suspicious and i >= 5 and i < 10 else 14
|
||||||
|
|
||||||
|
tx_time = base_time + timedelta(hours=i * 3)
|
||||||
|
tx_time = tx_time.replace(hour=tx_hour)
|
||||||
|
|
||||||
|
tx = Transaction(
|
||||||
|
id=uuid.uuid4(), external_id=f"seed_tx_{i:04d}", amount=amount,
|
||||||
|
merchant_name=name, merchant_category=cat,
|
||||||
|
latitude=tx_lat, longitude=tx_lon,
|
||||||
|
customer_id="tok_cust_001",
|
||||||
|
channel="web" if i % 2 == 0 else "mobile",
|
||||||
|
transaction_at=tx_time, ingested_at=tx_time,
|
||||||
|
ruleset_version="default",
|
||||||
|
raw_payload={"external_id": f"seed_tx_{i:04d}", "amount": amount},
|
||||||
|
)
|
||||||
|
db.add(tx)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
context = get_customer_context("tok_cust_001")
|
||||||
|
result = await evaluate_transaction(tx, loaded_rules, context)
|
||||||
|
score = result["score"]
|
||||||
|
|
||||||
|
audit = AuditLog(
|
||||||
|
trace_id=uuid.uuid4(), event_type="transaction_evaluated",
|
||||||
|
actor="system",
|
||||||
|
payload={"transaction_id": str(tx.id), "score": score, "triggered_rules": result["triggered_rules"]},
|
||||||
|
ruleset_version="default",
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
|
||||||
|
if score >= settings.ALERT_THRESHOLD:
|
||||||
|
alert = FraudAlert(
|
||||||
|
transaction_id=tx.id, score=score,
|
||||||
|
triggered_rules=result["triggered_rules"],
|
||||||
|
explanation=result["explanation"],
|
||||||
|
status="pending" if i % 3 != 0 else ("confirmed" if i % 3 == 0 else "pending"),
|
||||||
|
created_at=tx_time,
|
||||||
|
ruleset_version="default",
|
||||||
|
)
|
||||||
|
db.add(alert)
|
||||||
|
alert_count += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
print(f"Seeded 50 transactions, {alert_count} alerts generated")
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
print("Seed complete!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(seed())
|
||||||
0
backend/fraudshield/services/__init__.py
Normal file
0
backend/fraudshield/services/__init__.py
Normal file
103
backend/fraudshield/services/alerts.py
Normal file
103
backend/fraudshield/services/alerts.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, and_
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fraudshield.models.alert import FraudAlert
|
||||||
|
from fraudshield.models.audit import AuditLog
|
||||||
|
from fraudshield.schemas.alert import AlertListResponse, AlertSummary
|
||||||
|
|
||||||
|
|
||||||
|
async def list_alerts(
|
||||||
|
db: AsyncSession,
|
||||||
|
status: str | None = None,
|
||||||
|
min_score: int | None = None,
|
||||||
|
from_date: datetime | None = None,
|
||||||
|
to_date: datetime | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> AlertListResponse:
|
||||||
|
base_query = select(FraudAlert).join(FraudAlert.transaction)
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
if status:
|
||||||
|
conditions.append(FraudAlert.status == status)
|
||||||
|
if min_score is not None:
|
||||||
|
conditions.append(FraudAlert.score >= min_score)
|
||||||
|
if from_date:
|
||||||
|
conditions.append(FraudAlert.created_at >= from_date)
|
||||||
|
if to_date:
|
||||||
|
conditions.append(FraudAlert.created_at <= to_date)
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
base_query = base_query.where(and_(*conditions))
|
||||||
|
|
||||||
|
count_query = select(func.count()).select_from(base_query.subquery())
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
query = base_query.order_by(FraudAlert.created_at.desc()).offset(offset).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
alerts = result.scalars().all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for alert in alerts:
|
||||||
|
tx = alert.transaction
|
||||||
|
top_rule = alert.triggered_rules[0]["rule_name"] if alert.triggered_rules else "unknown"
|
||||||
|
items.append(
|
||||||
|
AlertSummary(
|
||||||
|
id=alert.id,
|
||||||
|
score=alert.score,
|
||||||
|
status=alert.status,
|
||||||
|
amount=float(tx.amount),
|
||||||
|
merchant_name=tx.merchant_name,
|
||||||
|
top_rule=top_rule,
|
||||||
|
created_at=alert.created_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AlertListResponse(items=items, total=total, limit=limit, offset=offset)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_alert(db: AsyncSession, alert_id: str) -> FraudAlert | None:
|
||||||
|
query = select(FraudAlert).options(selectinload(FraudAlert.transaction)).where(FraudAlert.id == alert_id)
|
||||||
|
result = await db.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def decide_alert(
|
||||||
|
db: AsyncSession,
|
||||||
|
alert_id: str,
|
||||||
|
decision: str,
|
||||||
|
user: dict,
|
||||||
|
justification: str | None = None,
|
||||||
|
) -> FraudAlert:
|
||||||
|
alert = await get_alert(db, alert_id)
|
||||||
|
if alert is None:
|
||||||
|
raise ValueError("Alert not found")
|
||||||
|
if alert.status != "pending":
|
||||||
|
raise ValueError("Alert already decided")
|
||||||
|
|
||||||
|
alert.status = decision
|
||||||
|
alert.decision = decision
|
||||||
|
alert.decided_by = user["email"]
|
||||||
|
alert.decided_at = datetime.now(timezone.utc)
|
||||||
|
alert.justification = justification
|
||||||
|
|
||||||
|
audit = AuditLog(
|
||||||
|
trace_id=uuid.uuid4(),
|
||||||
|
event_type="decision_made",
|
||||||
|
actor=user["email"],
|
||||||
|
payload={
|
||||||
|
"alert_id": str(alert.id),
|
||||||
|
"transaction_id": str(alert.transaction_id),
|
||||||
|
"decision": decision,
|
||||||
|
"justification": justification,
|
||||||
|
"previous_score": alert.score,
|
||||||
|
},
|
||||||
|
ruleset_version=alert.ruleset_version,
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(alert)
|
||||||
|
return alert
|
||||||
73
backend/fraudshield/services/detection.py
Normal file
73
backend/fraudshield/services/detection.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from fraudshield.config import settings
|
||||||
|
from fraudshield.models.alert import FraudAlert
|
||||||
|
from fraudshield.models.audit import AuditLog
|
||||||
|
from fraudshield.engine.loader import load_active_rules
|
||||||
|
from fraudshield.engine.evaluator import evaluate_transaction
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer_context(customer_id: str) -> dict:
|
||||||
|
return {
|
||||||
|
"customer_profile": {
|
||||||
|
"avg_amount_30d": 200.0,
|
||||||
|
"avg_amount_90d": 180.0,
|
||||||
|
"common_categories": ["alimentação", "transporte", "saúde", "educação"],
|
||||||
|
"common_channels": ["web", "mobile"],
|
||||||
|
"home_latitude": -23.5505,
|
||||||
|
"home_longitude": -46.6333,
|
||||||
|
"night_transactions_pct": 5.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def process_transaction(db: AsyncSession, transaction, context: dict):
|
||||||
|
rules = await load_active_rules(db)
|
||||||
|
if not rules:
|
||||||
|
rules = await load_active_rules(db)
|
||||||
|
|
||||||
|
result = await evaluate_transaction(transaction, rules, context)
|
||||||
|
score = result["score"]
|
||||||
|
|
||||||
|
trace_id = uuid.uuid4()
|
||||||
|
audit = AuditLog(
|
||||||
|
trace_id=trace_id,
|
||||||
|
event_type="transaction_evaluated",
|
||||||
|
actor="system",
|
||||||
|
payload={
|
||||||
|
"transaction_id": str(transaction.id),
|
||||||
|
"score": score,
|
||||||
|
"triggered_rules": result["triggered_rules"],
|
||||||
|
},
|
||||||
|
ruleset_version="default",
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
|
||||||
|
if score >= settings.ALERT_THRESHOLD:
|
||||||
|
alert = FraudAlert(
|
||||||
|
transaction_id=transaction.id,
|
||||||
|
score=score,
|
||||||
|
triggered_rules=result["triggered_rules"],
|
||||||
|
explanation=result["explanation"],
|
||||||
|
status="pending",
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
ruleset_version="default",
|
||||||
|
)
|
||||||
|
db.add(alert)
|
||||||
|
|
||||||
|
alert_audit = AuditLog(
|
||||||
|
trace_id=trace_id,
|
||||||
|
event_type="alert_created",
|
||||||
|
actor="system",
|
||||||
|
payload={
|
||||||
|
"alert_id": str(alert.id),
|
||||||
|
"transaction_id": str(transaction.id),
|
||||||
|
"score": score,
|
||||||
|
"triggered_rules": result["triggered_rules"],
|
||||||
|
},
|
||||||
|
ruleset_version="default",
|
||||||
|
)
|
||||||
|
db.add(alert_audit)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
36
backend/fraudshield/services/ingestion.py
Normal file
36
backend/fraudshield/services/ingestion.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from fraudshield.models.transaction import Transaction
|
||||||
|
from fraudshield.schemas.transaction import TransactionIngestRequest
|
||||||
|
from fraudshield.services.detection import process_transaction, get_customer_context
|
||||||
|
|
||||||
|
|
||||||
|
async def ingest_transaction(db: AsyncSession, data: TransactionIngestRequest) -> dict:
|
||||||
|
trace_id = uuid.uuid4()
|
||||||
|
|
||||||
|
transaction = Transaction(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
external_id=data.external_id,
|
||||||
|
amount=data.amount,
|
||||||
|
merchant_name=data.merchant_name,
|
||||||
|
merchant_category=data.merchant_category,
|
||||||
|
latitude=data.latitude,
|
||||||
|
longitude=data.longitude,
|
||||||
|
customer_id=data.customer_id,
|
||||||
|
channel=data.channel,
|
||||||
|
transaction_at=data.transaction_at,
|
||||||
|
ingested_at=datetime.now(timezone.utc),
|
||||||
|
ruleset_version="default",
|
||||||
|
raw_payload=json.loads(data.model_dump_json()),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(transaction)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(transaction)
|
||||||
|
|
||||||
|
context = get_customer_context(data.customer_id)
|
||||||
|
await process_transaction(db, transaction, context)
|
||||||
|
|
||||||
|
return {"transaction_id": str(transaction.id), "status": "accepted", "trace_id": str(trace_id)}
|
||||||
28
backend/pyproject.toml
Normal file
28
backend/pyproject.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
[project]
|
||||||
|
name = "fraudshield"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Motor de detecção de fraudes bancárias"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi[standard]>=0.115.0",
|
||||||
|
"uvicorn[standard]>=0.32.0",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.36",
|
||||||
|
"asyncpg>=0.30.0",
|
||||||
|
"alembic>=1.14.0",
|
||||||
|
"pydantic-settings>=2.6.0",
|
||||||
|
"redis[hiredis]>=5.2.0",
|
||||||
|
"python-jose[cryptography]>=3.3.0",
|
||||||
|
"passlib[bcrypt]>=1.7.4",
|
||||||
|
"structlog>=24.4.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3.0",
|
||||||
|
"pytest-asyncio>=0.24.0",
|
||||||
|
"httpx>=0.28.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
1548
backend/uv.lock
generated
Normal file
1548
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: fraudshield
|
||||||
|
POSTGRES_USER: fraudshield
|
||||||
|
POSTGRES_PASSWORD: fraudshield_dev
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<title>FraudShield</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 font-sans antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3258
frontend/package-lock.json
generated
Normal file
3258
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "fraudshield-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0",
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"recharts": "^2.15.0",
|
||||||
|
"lucide-react": "^0.460.0",
|
||||||
|
"clsx": "^2.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "~5.6.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
38
frontend/src/App.tsx
Normal file
38
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center p-8 bg-white rounded-xl shadow-sm border">
|
||||||
|
<div className="text-4xl mb-3">🚧</div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700">{title}</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Em desenvolvimento</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route element={<ProtectedRoute />}>
|
||||||
|
<Route element={<Shell />}>
|
||||||
|
<Route path="/" element={<Navigate to="/alerts" replace />} />
|
||||||
|
<Route path="/alerts" element={<AlertsPage />} />
|
||||||
|
<Route element={<AdminRoute />}>
|
||||||
|
<Route path="/rules" element={<Placeholder title="Gestão de Regras" />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/dashboard" element={<Placeholder title="Dashboard" />} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
frontend/src/api/client.ts
Normal file
113
frontend/src/api/client.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
const API_BASE = '/api/v1';
|
||||||
|
|
||||||
|
function getAuthHeaders(): Record<string, string> {
|
||||||
|
const token = localStorage.getItem('fraudshield_token');
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: { ...getAuthHeaders(), ...((options.headers as Record<string, string>) || {}) },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||||
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertFilters {
|
||||||
|
status?: string;
|
||||||
|
min_score?: number;
|
||||||
|
from_date?: string;
|
||||||
|
to_date?: string;
|
||||||
|
triggered_rule?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriggeredRule {
|
||||||
|
rule_id: string;
|
||||||
|
rule_name: string;
|
||||||
|
score: number;
|
||||||
|
explanation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertSummary {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
status: string;
|
||||||
|
amount: number;
|
||||||
|
merchant_name: string;
|
||||||
|
top_rule: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertDetail {
|
||||||
|
id: string;
|
||||||
|
transaction_id: string;
|
||||||
|
score: number;
|
||||||
|
triggered_rules: TriggeredRule[];
|
||||||
|
explanation: string;
|
||||||
|
status: string;
|
||||||
|
assigned_to?: string | null;
|
||||||
|
decision?: string | null;
|
||||||
|
decided_by?: string | null;
|
||||||
|
decided_at?: string | null;
|
||||||
|
justification?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
amount: number;
|
||||||
|
merchant_name: string;
|
||||||
|
merchant_category: string;
|
||||||
|
channel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertListResponse {
|
||||||
|
items: AlertSummary[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertDecisionRequest {
|
||||||
|
decision: string;
|
||||||
|
justification?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionAccepted {
|
||||||
|
transaction_id: string;
|
||||||
|
status: string;
|
||||||
|
trace_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardMetrics {
|
||||||
|
total_transactions: number;
|
||||||
|
total_alerts: number;
|
||||||
|
fraud_rate_pct: number;
|
||||||
|
false_positive_rate_pct: number;
|
||||||
|
alerts_by_status: { pending: number; confirmed: number; false_positive: number; escalated: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAlerts(params: AlertFilters = {}): Promise<AlertListResponse> {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
Object.entries(params).forEach(([k, v]) => { if (v !== undefined) qs.set(k, String(v)); });
|
||||||
|
return apiFetch<AlertListResponse>(`/alerts?${qs.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAlert(id: string): Promise<AlertDetail> {
|
||||||
|
return apiFetch<AlertDetail>(`/alerts/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideAlert(id: string, data: AlertDecisionRequest): Promise<AlertDetail> {
|
||||||
|
return apiFetch<AlertDetail>(`/alerts/${id}/decide`, { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDashboardMetrics(period = '24h'): Promise<DashboardMetrics> {
|
||||||
|
return apiFetch<DashboardMetrics>(`/dashboard/metrics?period=${period}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getAuthHeaders };
|
||||||
38
frontend/src/components/alerts/AlertCard.tsx
Normal file
38
frontend/src/components/alerts/AlertCard.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import type { AlertSummary } from '../../api/client';
|
||||||
|
import { formatCurrency, formatRelative, getScoreColor, getScoreBg, getStatusBadge, cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
alert: AlertSummary;
|
||||||
|
isSelected: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertCard({ alert, isSelected, onClick }: Props) {
|
||||||
|
const badge = getStatusBadge(alert.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 p-3 cursor-pointer transition-all duration-150 border-l-4',
|
||||||
|
isSelected ? 'bg-blue-50 border-blue-500' : 'bg-white border-transparent hover:bg-gray-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn('w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold text-white shrink-0', getScoreColor(alert.score))}>
|
||||||
|
{alert.score}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-semibold text-sm text-gray-900">{formatCurrency(alert.amount)}</span>
|
||||||
|
<span className="text-xs text-gray-400 shrink-0">{formatRelative(alert.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 truncate">{alert.merchant_name}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="text-xs bg-gray-100 text-gray-600 rounded px-1.5 py-0.5">{alert.top_rule}</span>
|
||||||
|
<span className={cn('text-xs px-1.5 py-0.5 rounded font-medium', badge.cls)}>{badge.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
frontend/src/components/alerts/AlertDetail.tsx
Normal file
200
frontend/src/components/alerts/AlertDetail.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { AlertDetail } from '../../api/client';
|
||||||
|
import { useAlertDetail, useDecideAlert } from '../../hooks/useAlerts';
|
||||||
|
import { formatCurrency, formatDate, getScoreColor, getScoreBg, getStatusBadge, cn } from '../../lib/utils';
|
||||||
|
import { ShieldOff, CheckCircle, ArrowUp, Loader2, FileText } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
alertId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertDetailPanel({ alertId }: Props) {
|
||||||
|
const { data: alert, isLoading } = useAlertDetail(alertId);
|
||||||
|
const { mutate: doDecide } = useDecideAlert();
|
||||||
|
const [decision, setDecision] = useState<string | null>(null);
|
||||||
|
const [justification, setJustification] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
if (!alertId) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="text-sm">Selecione um alerta para ver os detalhes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!alert) {
|
||||||
|
return <div className="p-4 text-center text-red-500 text-sm">Alerta não encontrado</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = getStatusBadge(alert.status);
|
||||||
|
const isPending = alert.status === 'pending';
|
||||||
|
|
||||||
|
const handleDecide = async (d: string) => {
|
||||||
|
if (d === 'confirmed') {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await doDecide(alert.id, { decision: d });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDecision(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitWithJustification = async () => {
|
||||||
|
if (!decision) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await doDecide(alert.id, { decision, justification });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
setDecision(null);
|
||||||
|
setJustification('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-5 overflow-y-auto h-full">
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 p-4 bg-gray-50 rounded-xl">
|
||||||
|
<div className={cn('w-16 h-16 rounded-full flex items-center justify-center text-xl font-bold text-white', getScoreColor(alert.score))}>
|
||||||
|
{alert.score}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">Score de Risco</p>
|
||||||
|
<p className="text-xs text-gray-500">de 100 pontos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-xl p-4 space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Transação</h3>
|
||||||
|
<Row label="Valor" value={formatCurrency(alert.amount || 0)} />
|
||||||
|
<Row label="Estabelecimento" value={alert.merchant_name || '-'} />
|
||||||
|
<Row label="Categoria" value={alert.merchant_category || '-'} />
|
||||||
|
<Row label="Canal" value={alert.channel || '-'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{alert.triggered_rules && alert.triggered_rules.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900">Regras Acionadas</h3>
|
||||||
|
{alert.triggered_rules.map((rule, i) => (
|
||||||
|
<div key={i} className="bg-white border rounded-xl p-3">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-sm font-medium text-gray-900">{rule.rule_name}</span>
|
||||||
|
<span className={cn('text-xs px-2 py-0.5 rounded-full font-medium', getScoreBg(rule.score))}>
|
||||||
|
+{rule.score}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">{rule.explanation}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
|
<p className="text-sm text-blue-900 whitespace-pre-line">{alert.explanation}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{isPending && !decision && (
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDecide('confirmed')}
|
||||||
|
disabled={submitting}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ShieldOff className="w-4 h-4" />}
|
||||||
|
Confirmar Fraude
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDecide('false_positive')}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Falso Positivo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDecide('escalated')}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 bg-yellow-500 hover:bg-yellow-600 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-4 h-4" />
|
||||||
|
Escalar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{decision && decision !== 'confirmed' && (
|
||||||
|
<div className="border rounded-xl p-4 space-y-3">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900">
|
||||||
|
Justificativa para "{decision === 'false_positive' ? 'Falso Positivo' : 'Escalar'}"
|
||||||
|
</h4>
|
||||||
|
<textarea
|
||||||
|
value={justification}
|
||||||
|
onChange={(e) => setJustification(e.target.value)}
|
||||||
|
className="w-full border rounded-lg p-2 text-sm"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Descreva o motivo da decisão..."
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={submitWithJustification}
|
||||||
|
disabled={submitting || !justification.trim()}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white text-sm rounded-lg"
|
||||||
|
>
|
||||||
|
{submitting ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Confirmar'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setDecision(null)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{!isPending && alert.decision && (
|
||||||
|
<div className="border rounded-xl p-4 space-y-2 bg-gray-50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('text-sm px-3 py-1 rounded-full font-medium', getStatusBadge(alert.status).cls)}>
|
||||||
|
{getStatusBadge(alert.status).label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{alert.decided_by && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Decidido por <span className="font-medium">{alert.decided_by}</span>
|
||||||
|
{alert.decided_at && ` em ${formatDate(alert.decided_at)}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{alert.justification && (
|
||||||
|
<p className="text-sm text-gray-600 italic mt-1">{alert.justification}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">{label}</span>
|
||||||
|
<span className="text-gray-900 font-medium">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
frontend/src/components/alerts/AlertQueue.tsx
Normal file
86
frontend/src/components/alerts/AlertQueue.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { AlertFilters, AlertSummary } from '../../api/client';
|
||||||
|
import { useAlertQueue } from '../../hooks/useAlerts';
|
||||||
|
import AlertCard from './AlertCard';
|
||||||
|
import { Search, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertQueue({ selectedId, onSelect }: Props) {
|
||||||
|
const [filters, setFilters] = useState<AlertFilters>({ status: 'pending' });
|
||||||
|
const { data, isLoading, isError } = useAlertQueue(filters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="p-3 border-b bg-white shrink-0 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={filters.status || ''}
|
||||||
|
onChange={(e) => setFilters(f => ({ ...f, status: e.target.value || undefined }))}
|
||||||
|
className="text-xs border rounded-lg px-2 py-1.5 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="pending">Pendentes</option>
|
||||||
|
<option value="confirmed">Confirmados</option>
|
||||||
|
<option value="false_positive">Falsos Positivos</option>
|
||||||
|
<option value="escalated">Escalados</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Score mín."
|
||||||
|
className="text-xs border rounded-lg px-2 py-1.5 w-20"
|
||||||
|
onChange={(e) => setFilters(f => ({ ...f, min_score: e.target.value ? Number(e.target.value) : undefined }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto divide-y divide-gray-100">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{[1, 2, 3, 4, 5].map(i => (
|
||||||
|
<div key={i} className="flex items-center gap-3 animate-pulse">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gray-200" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-24" />
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="p-4 text-center text-sm text-red-600">
|
||||||
|
Erro ao carregar alertas
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && data.items.length === 0 && (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<Search className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-500">Nenhum alerta encontrado</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.items.map(alert => (
|
||||||
|
<AlertCard
|
||||||
|
key={alert.id}
|
||||||
|
alert={alert}
|
||||||
|
isSelected={alert.id === selectedId}
|
||||||
|
onClick={() => onSelect(alert.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<div className="p-2 border-t bg-white text-xs text-gray-500 text-center shrink-0">
|
||||||
|
{data.total} alertas
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/src/components/layout/ProtectedRoute.tsx
Normal file
16
frontend/src/components/layout/ProtectedRoute.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Navigate, Outlet } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
|
||||||
|
export function ProtectedRoute() {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
if (loading) return <div className="flex items-center justify-center h-screen"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" /></div>;
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminRoute() {
|
||||||
|
const { isAdmin, loading } = useAuth();
|
||||||
|
if (loading) return null;
|
||||||
|
if (!isAdmin) return <Navigate to="/alerts" replace />;
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
89
frontend/src/components/layout/Shell.tsx
Normal file
89
frontend/src/components/layout/Shell.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { Outlet, NavLink, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
|
import { Shield, AlertTriangle, ShieldCheck, BarChart3, Menu, X, LogOut } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export default function Shell() {
|
||||||
|
const { user, isAdmin, logout } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const pageTitles: Record<string, string> = {
|
||||||
|
'/alerts': 'Alertas',
|
||||||
|
'/rules': 'Regras',
|
||||||
|
'/dashboard': 'Dashboard',
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = pageTitles[location.pathname] || 'FraudShield';
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ to: '/alerts', icon: AlertTriangle, label: 'Alertas' },
|
||||||
|
...(isAdmin ? [{ to: '/rules', icon: ShieldCheck, label: 'Regras' }] : []),
|
||||||
|
{ to: '/dashboard', icon: BarChart3, label: 'Dashboard' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-50">
|
||||||
|
<aside className={`
|
||||||
|
fixed inset-y-0 left-0 z-50 bg-[#1a1a2e] text-white transition-all duration-200
|
||||||
|
${sidebarOpen ? 'w-60' : 'w-16'}
|
||||||
|
md:relative md:translate-x-0
|
||||||
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
|
||||||
|
`}>
|
||||||
|
<div className="flex items-center gap-2 p-4 border-b border-gray-700">
|
||||||
|
<Shield className="w-6 h-6 text-blue-400 shrink-0" />
|
||||||
|
{sidebarOpen && <span className="font-semibold text-sm">FraudShield</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
className="md:hidden absolute top-4 right-[-40px] bg-[#1a1a2e] p-2 rounded-r-lg text-white"
|
||||||
|
>
|
||||||
|
{sidebarOpen ? <X className="w-4 h-4" /> : <Menu className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav className="mt-4 flex flex-col gap-1 px-2">
|
||||||
|
{links.map(({ to, icon: Icon, label }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
||||||
|
isActive ? 'bg-blue-600 text-white' : 'text-gray-300 hover:bg-gray-700 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 shrink-0" />
|
||||||
|
{sidebarOpen && <span>{label}</span>}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
<header className="h-16 bg-white border-b flex items-center justify-between px-4 md:px-6 shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="md:hidden p-1">
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-lg font-semibold text-gray-900">{title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-500 hidden sm:inline">{user?.name}</span>
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-700 font-medium">
|
||||||
|
{user?.role}
|
||||||
|
</span>
|
||||||
|
<button onClick={logout} className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/src/contexts/AuthContext.tsx
Normal file
87
frontend/src/contexts/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { createContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isAnalyst: boolean;
|
||||||
|
isSenior: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthState>(null!);
|
||||||
|
|
||||||
|
function parseJWT(token: string): User | null {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
return {
|
||||||
|
id: payload.sub || '',
|
||||||
|
email: payload.email || '',
|
||||||
|
role: payload.role || 'analyst',
|
||||||
|
name: (payload.email || '').split('@')[0],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('fraudshield_token');
|
||||||
|
if (stored) {
|
||||||
|
const u = parseJWT(stored);
|
||||||
|
if (u) { setUser(u); setToken(stored); }
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const form = new URLSearchParams();
|
||||||
|
form.set('username', email);
|
||||||
|
form.set('password', password);
|
||||||
|
const res = await fetch('/api/v1/auth/token', { method: 'POST', body: form });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: 'Login failed' }));
|
||||||
|
throw new Error(err.detail || 'Login failed');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
localStorage.setItem('fraudshield_token', data.access_token);
|
||||||
|
const u = parseJWT(data.access_token);
|
||||||
|
if (u) { setUser(u); setToken(data.access_token); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
localStorage.removeItem('fraudshield_token');
|
||||||
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const role = user?.role || 'analyst';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{
|
||||||
|
user, token, loading,
|
||||||
|
isAuthenticated: !!token,
|
||||||
|
isAdmin: role === 'admin',
|
||||||
|
isAnalyst: role === 'analyst',
|
||||||
|
isSenior: role === 'senior',
|
||||||
|
login, logout,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/hooks/useAlerts.ts
Normal file
30
frontend/src/hooks/useAlerts.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { listAlerts, getAlert, decideAlert, type AlertFilters, type AlertDetail, type AlertDecisionRequest } from '../api/client';
|
||||||
|
|
||||||
|
export function useAlertQueue(filters: AlertFilters = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['alerts', filters],
|
||||||
|
queryFn: () => listAlerts({ limit: 50, ...filters }),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlertDetail(id: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['alert', id],
|
||||||
|
queryFn: () => getAlert(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDecideAlert() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return {
|
||||||
|
mutate: async (id: string, data: AlertDecisionRequest) => {
|
||||||
|
const result = await decideAlert(id, data);
|
||||||
|
qc.invalidateQueries({ queryKey: ['alerts'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['alert', id] });
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
6
frontend/src/hooks/useAuth.ts
Normal file
6
frontend/src/hooks/useAuth.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { AuthContext } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
45
frontend/src/lib/utils.ts
Normal file
45
frontend/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
export const cn = (...args: Parameters<typeof clsx>) => clsx(args);
|
||||||
|
|
||||||
|
export function formatCurrency(value: number): string {
|
||||||
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const min = Math.floor(diff / 60000);
|
||||||
|
if (min < 1) return 'agora';
|
||||||
|
if (min < 60) return `há ${min} min`;
|
||||||
|
const h = Math.floor(min / 60);
|
||||||
|
if (h < 24) return `há ${h}h`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
return `há ${d}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScoreColor(score: number): string {
|
||||||
|
if (score <= 30) return 'bg-green-500';
|
||||||
|
if (score <= 60) return 'bg-yellow-500';
|
||||||
|
if (score <= 80) return 'bg-orange-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScoreBg(score: number): string {
|
||||||
|
if (score <= 30) return 'bg-green-100 text-green-800';
|
||||||
|
if (score <= 60) return 'bg-yellow-100 text-yellow-800';
|
||||||
|
if (score <= 80) return 'bg-orange-100 text-orange-800';
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusBadge(status: string): { label: string; cls: string } {
|
||||||
|
const map: Record<string, { label: string; cls: string }> = {
|
||||||
|
pending: { label: 'Pendente', cls: 'bg-blue-100 text-blue-800' },
|
||||||
|
confirmed: { label: 'Fraude', cls: 'bg-red-100 text-red-800' },
|
||||||
|
false_positive: { label: 'Falso Positivo', cls: 'bg-green-100 text-green-800' },
|
||||||
|
escalated: { label: 'Escalado', cls: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
};
|
||||||
|
return map[status] || { label: status, cls: 'bg-gray-100 text-gray-800' };
|
||||||
|
}
|
||||||
18
frontend/src/main.tsx
Normal file
18
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
18
frontend/src/pages/AlertsPage.tsx
Normal file
18
frontend/src/pages/AlertsPage.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import AlertQueue from '../components/alerts/AlertQueue';
|
||||||
|
import AlertDetailPanel from '../components/alerts/AlertDetail';
|
||||||
|
|
||||||
|
export default function AlertsPage() {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
<div className="w-2/5 border-r bg-white overflow-hidden">
|
||||||
|
<AlertQueue selectedId={selectedId} onSelect={setSelectedId} />
|
||||||
|
</div>
|
||||||
|
<div className="w-3/5 overflow-hidden">
|
||||||
|
<AlertDetailPanel alertId={selectedId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
frontend/src/pages/LoginPage.tsx
Normal file
89
frontend/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { Shield, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
navigate('/alerts');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Falha no login');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-900 via-blue-900 to-gray-800 p-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-8">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-blue-600 mb-4">
|
||||||
|
<Shield className="w-7 h-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">FraudShield</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Motor de Detecção de Fraudes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm"
|
||||||
|
placeholder="analista@fraudshield.local"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Senha</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-lg text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
Entrar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 p-3 bg-gray-50 rounded-lg text-xs text-gray-500">
|
||||||
|
<p className="font-medium mb-1">Credenciais de teste:</p>
|
||||||
|
<p>analista@fraudshield.local / qualquer senha</p>
|
||||||
|
<p>admin@fraudshield.local / qualquer senha</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
frontend/tailwind.config.ts
Normal file
13
frontend/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
sidebar: '#1a1a2e',
|
||||||
|
accent: '#3b82f6',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"baseUrl": "."
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
11
frontend/vite.config.ts
Normal file
11
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue