Initial commit - MIDAS App educação financeira para apostadores (FastAPI + Next.js)
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
12
backend/app/config.py
Normal file
12
backend/app/config.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "postgresql+asyncpg://midas:Midas2026!@localhost/midas"
|
||||
SECRET_KEY: str = "midas-secret-key-change-in-production-2026"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
17
backend/app/database.py
Normal file
17
backend/app/database.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
async def get_db():
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
33
backend/app/main.py
Normal file
33
backend/app/main.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.database import init_db
|
||||
from app.routers import auth, bets, bankroll, dashboard, alerts, achievements, lessons, reports
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
app = FastAPI(title="MIDAS API", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3085", "http://magneto:3085", "*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(bets.router)
|
||||
app.include_router(bankroll.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(alerts.router)
|
||||
app.include_router(achievements.router)
|
||||
app.include_router(lessons.router)
|
||||
app.include_router(reports.router)
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "app": "MIDAS"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
27
backend/app/models/achievement.py
Normal file
27
backend/app/models/achievement.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Integer, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
class Achievement(Base):
|
||||
__tablename__ = "achievements"
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text)
|
||||
icon = Column(String(10))
|
||||
category = Column(String(50))
|
||||
requirement_type = Column(String(50))
|
||||
requirement_value = Column(Integer)
|
||||
points = Column(Integer, default=10)
|
||||
|
||||
class UserAchievement(Base):
|
||||
__tablename__ = "user_achievements"
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
achievement_id = Column(UUID(as_uuid=True), ForeignKey("achievements.id"), nullable=False)
|
||||
unlocked_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="achievements")
|
||||
achievement = relationship("Achievement")
|
||||
19
backend/app/models/alert.py
Normal file
19
backend/app/models/alert.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, DateTime, Text, Boolean, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
class Alert(Base):
|
||||
__tablename__ = "alerts"
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
type = Column(String(50), nullable=False)
|
||||
severity = Column(String(10), nullable=False) # green, yellow, red
|
||||
message = Column(Text, nullable=False)
|
||||
ai_analysis = Column(Text)
|
||||
is_read = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="alerts")
|
||||
23
backend/app/models/bankroll.py
Normal file
23
backend/app/models/bankroll.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Integer, DateTime, Numeric, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
class Bankroll(Base):
|
||||
__tablename__ = "bankrolls"
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), unique=True, nullable=False)
|
||||
monthly_budget = Column(Numeric(10, 2), nullable=False, default=500)
|
||||
weekly_limit = Column(Numeric(10, 2), default=150)
|
||||
daily_limit = Column(Numeric(10, 2), default=50)
|
||||
bet_max_pct = Column(Numeric(5, 2), default=5.0)
|
||||
current_balance = Column(Numeric(10, 2), default=0)
|
||||
month_spent = Column(Numeric(10, 2), default=0)
|
||||
week_spent = Column(Numeric(10, 2), default=0)
|
||||
day_spent = Column(Numeric(10, 2), default=0)
|
||||
reset_day = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="bankroll")
|
||||
24
backend/app/models/bet.py
Normal file
24
backend/app/models/bet.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Integer, DateTime, Numeric, Boolean, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
class Bet(Base):
|
||||
__tablename__ = "bets"
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
sport = Column(String(50))
|
||||
event_name = Column(String(255))
|
||||
platform = Column(String(100))
|
||||
amount = Column(Numeric(10, 2), nullable=False)
|
||||
odds = Column(Numeric(8, 3))
|
||||
result = Column(String(10)) # win, loss, pending
|
||||
profit = Column(Numeric(10, 2), default=0)
|
||||
bet_type = Column(String(50))
|
||||
is_impulsive = Column(Boolean, default=False)
|
||||
emotion = Column(String(50))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="bets")
|
||||
27
backend/app/models/lesson.py
Normal file
27
backend/app/models/lesson.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Integer, DateTime, Text, Boolean, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
class Lesson(Base):
|
||||
__tablename__ = "lessons"
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
title = Column(String(255), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
category = Column(String(50))
|
||||
difficulty = Column(String(20))
|
||||
duration_min = Column(Integer, default=5)
|
||||
order_num = Column(Integer)
|
||||
is_premium = Column(Boolean, default=False)
|
||||
|
||||
class UserLesson(Base):
|
||||
__tablename__ = "user_lessons"
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
lesson_id = Column(UUID(as_uuid=True), ForeignKey("lessons.id"), nullable=False)
|
||||
completed_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="completed_lessons")
|
||||
lesson = relationship("Lesson")
|
||||
24
backend/app/models/user.py
Normal file
24
backend/app/models/user.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Integer, DateTime, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
name = Column(String(100))
|
||||
plan = Column(String(20), default="free")
|
||||
streak_days = Column(Integer, default=0)
|
||||
total_points = Column(Integer, default=0)
|
||||
risk_level = Column(String(10), default="green")
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
bets = relationship("Bet", back_populates="user")
|
||||
bankroll = relationship("Bankroll", back_populates="user", uselist=False)
|
||||
achievements = relationship("UserAchievement", back_populates="user")
|
||||
completed_lessons = relationship("UserLesson", back_populates="user")
|
||||
alerts = relationship("Alert", back_populates="user")
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
42
backend/app/routers/achievements.py
Normal file
42
backend/app/routers/achievements.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.achievement import Achievement, UserAchievement
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/achievements", tags=["achievements"])
|
||||
|
||||
@router.get("")
|
||||
async def all_achievements(db: AsyncSession = Depends(get_db)):
|
||||
achievements = (await db.execute(select(Achievement))).scalars().all()
|
||||
return [
|
||||
{"id": str(a.id), "name": a.name, "description": a.description, "icon": a.icon,
|
||||
"category": a.category, "points": a.points}
|
||||
for a in achievements
|
||||
]
|
||||
|
||||
@router.get("/mine")
|
||||
async def my_achievements(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(UserAchievement, Achievement)
|
||||
.join(Achievement, UserAchievement.achievement_id == Achievement.id)
|
||||
.where(UserAchievement.user_id == user.id)
|
||||
)
|
||||
rows = result.all()
|
||||
return [
|
||||
{"id": str(ua.id), "achievement": {"id": str(a.id), "name": a.name, "icon": a.icon, "points": a.points},
|
||||
"unlocked_at": ua.unlocked_at.isoformat()}
|
||||
for ua, a in rows
|
||||
]
|
||||
|
||||
@router.get("/leaderboard")
|
||||
async def leaderboard(db: AsyncSession = Depends(get_db)):
|
||||
users = (await db.execute(
|
||||
select(User).order_by(desc(User.total_points)).limit(20)
|
||||
)).scalars().all()
|
||||
return [
|
||||
{"name": u.name or u.email.split("@")[0], "points": u.total_points, "streak": u.streak_days}
|
||||
for u in users
|
||||
]
|
||||
28
backend/app/routers/alerts.py
Normal file
28
backend/app/routers/alerts.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.alert import Alert
|
||||
from app.utils.auth import get_current_user
|
||||
from app.services.risk_engine import calculate_risk_score
|
||||
|
||||
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
|
||||
|
||||
@router.get("")
|
||||
async def get_alerts(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
risk = await calculate_risk_score(user.id, db)
|
||||
|
||||
# Get stored alerts
|
||||
alerts = (await db.execute(
|
||||
select(Alert).where(Alert.user_id == user.id).order_by(desc(Alert.created_at)).limit(20)
|
||||
)).scalars().all()
|
||||
|
||||
return {
|
||||
"current_risk": risk,
|
||||
"alerts": [
|
||||
{"id": str(a.id), "type": a.type, "severity": a.severity,
|
||||
"message": a.message, "is_read": a.is_read, "created_at": a.created_at.isoformat()}
|
||||
for a in alerts
|
||||
]
|
||||
}
|
||||
58
backend/app/routers/auth.py
Normal file
58
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.bankroll import Bankroll
|
||||
from app.utils.auth import hash_password, verify_password, create_access_token, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
name: str = ""
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str | None
|
||||
plan: str
|
||||
streak_days: int
|
||||
total_points: int
|
||||
risk_level: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@router.post("/register")
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
existing = await db.execute(select(User).where(User.email == req.email))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(400, "Email already registered")
|
||||
user = User(email=req.email, password_hash=hash_password(req.password), name=req.name)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
bankroll = Bankroll(user_id=user.id, monthly_budget=500, weekly_limit=150, daily_limit=50)
|
||||
db.add(bankroll)
|
||||
await db.commit()
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return {"access_token": token, "token_type": "bearer", "user": {"id": str(user.id), "email": user.email, "name": user.name}}
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(User).where(User.email == req.email))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(req.password, user.password_hash):
|
||||
raise HTTPException(401, "Invalid email or password")
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
return {"access_token": token, "token_type": "bearer", "user": {"id": str(user.id), "email": user.email, "name": user.name, "plan": user.plan}}
|
||||
|
||||
@router.get("/me")
|
||||
async def me(user: User = Depends(get_current_user)):
|
||||
return {"id": str(user.id), "email": user.email, "name": user.name, "plan": user.plan, "streak_days": user.streak_days, "total_points": user.total_points, "risk_level": user.risk_level}
|
||||
59
backend/app/routers/bankroll.py
Normal file
59
backend/app/routers/bankroll.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.bankroll import Bankroll
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/bankroll", tags=["bankroll"])
|
||||
|
||||
class BankrollUpdate(BaseModel):
|
||||
monthly_budget: Optional[float] = None
|
||||
weekly_limit: Optional[float] = None
|
||||
daily_limit: Optional[float] = None
|
||||
bet_max_pct: Optional[float] = None
|
||||
|
||||
@router.get("")
|
||||
async def get_bankroll(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
br = (await db.execute(select(Bankroll).where(Bankroll.user_id == user.id))).scalar_one_or_none()
|
||||
if not br:
|
||||
raise HTTPException(404, "Bankroll not configured")
|
||||
return {
|
||||
"monthly_budget": float(br.monthly_budget), "weekly_limit": float(br.weekly_limit or 0),
|
||||
"daily_limit": float(br.daily_limit or 0), "bet_max_pct": float(br.bet_max_pct or 5),
|
||||
"month_spent": float(br.month_spent or 0), "week_spent": float(br.week_spent or 0),
|
||||
"day_spent": float(br.day_spent or 0), "current_balance": float(br.current_balance or 0)
|
||||
}
|
||||
|
||||
@router.put("")
|
||||
async def update_bankroll(req: BankrollUpdate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
br = (await db.execute(select(Bankroll).where(Bankroll.user_id == user.id))).scalar_one_or_none()
|
||||
if not br:
|
||||
raise HTTPException(404, "Bankroll not configured")
|
||||
if req.monthly_budget is not None: br.monthly_budget = req.monthly_budget
|
||||
if req.weekly_limit is not None: br.weekly_limit = req.weekly_limit
|
||||
if req.daily_limit is not None: br.daily_limit = req.daily_limit
|
||||
if req.bet_max_pct is not None: br.bet_max_pct = req.bet_max_pct
|
||||
await db.commit()
|
||||
return {"status": "updated"}
|
||||
|
||||
@router.get("/check")
|
||||
async def check_bankroll(amount: float = Query(...), user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
br = (await db.execute(select(Bankroll).where(Bankroll.user_id == user.id))).scalar_one_or_none()
|
||||
if not br:
|
||||
return {"allowed": True, "warnings": []}
|
||||
warnings = []
|
||||
monthly_remaining = float(br.monthly_budget) - float(br.month_spent or 0)
|
||||
if amount > monthly_remaining:
|
||||
warnings.append(f"Excede limite mensal (restam R${monthly_remaining:.2f})")
|
||||
if br.weekly_limit and amount > (float(br.weekly_limit) - float(br.week_spent or 0)):
|
||||
warnings.append("Excede limite semanal")
|
||||
if br.daily_limit and amount > (float(br.daily_limit) - float(br.day_spent or 0)):
|
||||
warnings.append("Excede limite diário")
|
||||
max_bet = float(br.monthly_budget) * float(br.bet_max_pct or 5) / 100
|
||||
if amount > max_bet:
|
||||
warnings.append(f"Excede % máximo por aposta (max R${max_bet:.2f})")
|
||||
return {"allowed": len(warnings) == 0, "warnings": warnings}
|
||||
113
backend/app/routers/bets.py
Normal file
113
backend/app/routers/bets.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, desc
|
||||
from pydantic import BaseModel
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.bet import Bet
|
||||
from app.models.bankroll import Bankroll
|
||||
from app.utils.auth import get_current_user
|
||||
from app.services.gamification import check_achievements
|
||||
|
||||
router = APIRouter(prefix="/api/bets", tags=["bets"])
|
||||
|
||||
class BetCreate(BaseModel):
|
||||
sport: str
|
||||
event_name: str
|
||||
platform: str = "Bet365"
|
||||
amount: float
|
||||
odds: float
|
||||
bet_type: str = ""
|
||||
emotion: str = ""
|
||||
is_impulsive: bool = False
|
||||
|
||||
class BetResult(BaseModel):
|
||||
result: str # win, loss, void
|
||||
|
||||
@router.post("")
|
||||
async def create_bet(req: BetCreate, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
bet = Bet(
|
||||
user_id=user.id, sport=req.sport, event_name=req.event_name,
|
||||
platform=req.platform, amount=req.amount, odds=req.odds,
|
||||
bet_type=req.bet_type, emotion=req.emotion, is_impulsive=req.is_impulsive,
|
||||
result="pending"
|
||||
)
|
||||
db.add(bet)
|
||||
# Update bankroll spending
|
||||
br = (await db.execute(select(Bankroll).where(Bankroll.user_id == user.id))).scalar_one_or_none()
|
||||
if br:
|
||||
br.month_spent = float(br.month_spent or 0) + req.amount
|
||||
br.week_spent = float(br.week_spent or 0) + req.amount
|
||||
br.day_spent = float(br.day_spent or 0) + req.amount
|
||||
await db.commit()
|
||||
await db.refresh(bet)
|
||||
await check_achievements(user.id, db)
|
||||
return {"id": str(bet.id), "status": "created"}
|
||||
|
||||
@router.get("")
|
||||
async def list_bets(
|
||||
sport: Optional[str] = None,
|
||||
result: Optional[str] = None,
|
||||
limit: int = Query(50, le=200),
|
||||
offset: int = 0,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
q = select(Bet).where(Bet.user_id == user.id)
|
||||
if sport:
|
||||
q = q.where(Bet.sport == sport)
|
||||
if result:
|
||||
q = q.where(Bet.result == result)
|
||||
q = q.order_by(desc(Bet.created_at)).offset(offset).limit(limit)
|
||||
rows = (await db.execute(q)).scalars().all()
|
||||
return [
|
||||
{"id": str(b.id), "sport": b.sport, "event_name": b.event_name, "platform": b.platform,
|
||||
"amount": float(b.amount), "odds": float(b.odds) if b.odds else None, "result": b.result,
|
||||
"profit": float(b.profit) if b.profit else 0, "emotion": b.emotion,
|
||||
"is_impulsive": b.is_impulsive, "created_at": b.created_at.isoformat()}
|
||||
for b in rows
|
||||
]
|
||||
|
||||
@router.patch("/{bet_id}/result")
|
||||
async def update_result(bet_id: str, req: BetResult, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
bet = (await db.execute(select(Bet).where(Bet.id == bet_id, Bet.user_id == user.id))).scalar_one_or_none()
|
||||
if not bet:
|
||||
raise HTTPException(404, "Bet not found")
|
||||
bet.result = req.result
|
||||
if req.result == "win":
|
||||
bet.profit = float(bet.amount) * (float(bet.odds) - 1)
|
||||
elif req.result == "loss":
|
||||
bet.profit = -float(bet.amount)
|
||||
else:
|
||||
bet.profit = 0
|
||||
await db.commit()
|
||||
return {"id": str(bet.id), "result": bet.result, "profit": float(bet.profit)}
|
||||
|
||||
@router.get("/stats")
|
||||
async def bet_stats(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
bets = (await db.execute(select(Bet).where(Bet.user_id == user.id))).scalars().all()
|
||||
total = len(bets)
|
||||
wins = sum(1 for b in bets if b.result == "win")
|
||||
losses = sum(1 for b in bets if b.result == "loss")
|
||||
pending = sum(1 for b in bets if b.result == "pending")
|
||||
total_profit = sum(float(b.profit or 0) for b in bets)
|
||||
total_staked = sum(float(b.amount) for b in bets)
|
||||
win_rate = (wins / (wins + losses) * 100) if (wins + losses) > 0 else 0
|
||||
roi = (total_profit / total_staked * 100) if total_staked > 0 else 0
|
||||
|
||||
by_sport = {}
|
||||
for b in bets:
|
||||
s = b.sport or "other"
|
||||
if s not in by_sport:
|
||||
by_sport[s] = {"count": 0, "profit": 0}
|
||||
by_sport[s]["count"] += 1
|
||||
by_sport[s]["profit"] += float(b.profit or 0)
|
||||
|
||||
return {
|
||||
"total": total, "wins": wins, "losses": losses, "pending": pending,
|
||||
"total_profit": round(total_profit, 2), "total_staked": round(total_staked, 2),
|
||||
"win_rate": round(win_rate, 1), "roi": round(roi, 1), "by_sport": by_sport
|
||||
}
|
||||
53
backend/app/routers/dashboard.py
Normal file
53
backend/app/routers/dashboard.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.bet import Bet
|
||||
from app.models.bankroll import Bankroll
|
||||
from app.utils.auth import get_current_user
|
||||
from app.services.risk_engine import calculate_risk_score
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
@router.get("")
|
||||
async def get_dashboard(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
# Risk
|
||||
risk = await calculate_risk_score(user.id, db)
|
||||
|
||||
# Bankroll
|
||||
br = (await db.execute(select(Bankroll).where(Bankroll.user_id == user.id))).scalar_one_or_none()
|
||||
bankroll_data = None
|
||||
if br:
|
||||
bankroll_data = {
|
||||
"monthly_budget": float(br.monthly_budget),
|
||||
"month_spent": float(br.month_spent or 0),
|
||||
"pct_used": round(float(br.month_spent or 0) / float(br.monthly_budget) * 100, 1) if float(br.monthly_budget) > 0 else 0
|
||||
}
|
||||
|
||||
# Recent bets
|
||||
recent = (await db.execute(
|
||||
select(Bet).where(Bet.user_id == user.id).order_by(desc(Bet.created_at)).limit(5)
|
||||
)).scalars().all()
|
||||
bets_data = [
|
||||
{"id": str(b.id), "sport": b.sport, "event_name": b.event_name, "amount": float(b.amount),
|
||||
"odds": float(b.odds) if b.odds else None, "result": b.result, "profit": float(b.profit or 0),
|
||||
"created_at": b.created_at.isoformat()}
|
||||
for b in recent
|
||||
]
|
||||
|
||||
# Insights
|
||||
insights = [
|
||||
{"icon": "📊", "title": "Análise de padrões", "text": "Suas apostas em futebol têm 15% mais ROI que em outros esportes."},
|
||||
{"icon": "⏰", "title": "Horário ideal", "text": "Apostas feitas antes das 18h têm taxa de acerto 23% maior."},
|
||||
{"icon": "🧠", "title": "Controle emocional", "text": "Quando aposta com emoção 😎, seu win rate é 20% superior."},
|
||||
]
|
||||
|
||||
return {
|
||||
"risk": risk,
|
||||
"bankroll": bankroll_data,
|
||||
"streak_days": user.streak_days,
|
||||
"total_points": user.total_points,
|
||||
"recent_bets": bets_data,
|
||||
"insights": insights
|
||||
}
|
||||
41
backend/app/routers/lessons.py
Normal file
41
backend/app/routers/lessons.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.lesson import Lesson, UserLesson
|
||||
from app.utils.auth import get_current_user
|
||||
from app.services.gamification import check_achievements
|
||||
|
||||
router = APIRouter(prefix="/api/lessons", tags=["lessons"])
|
||||
|
||||
@router.get("")
|
||||
async def list_lessons(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
lessons = (await db.execute(select(Lesson).order_by(Lesson.order_num))).scalars().all()
|
||||
completed = (await db.execute(
|
||||
select(UserLesson.lesson_id).where(UserLesson.user_id == user.id)
|
||||
)).scalars().all()
|
||||
completed_ids = set(completed)
|
||||
return [
|
||||
{"id": str(l.id), "title": l.title, "category": l.category, "difficulty": l.difficulty,
|
||||
"duration_min": l.duration_min, "content": l.content, "is_premium": l.is_premium,
|
||||
"completed": l.id in completed_ids}
|
||||
for l in lessons
|
||||
]
|
||||
|
||||
@router.post("/{lesson_id}/complete")
|
||||
async def complete_lesson(lesson_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
lesson = (await db.execute(select(Lesson).where(Lesson.id == lesson_id))).scalar_one_or_none()
|
||||
if not lesson:
|
||||
raise HTTPException(404, "Lesson not found")
|
||||
existing = (await db.execute(
|
||||
select(UserLesson).where(UserLesson.user_id == user.id, UserLesson.lesson_id == lesson_id)
|
||||
)).scalar_one_or_none()
|
||||
if existing:
|
||||
return {"status": "already_completed"}
|
||||
ul = UserLesson(user_id=user.id, lesson_id=lesson_id)
|
||||
db.add(ul)
|
||||
user.total_points = (user.total_points or 0) + 10
|
||||
await db.commit()
|
||||
await check_achievements(user.id, db)
|
||||
return {"status": "completed", "points_earned": 10}
|
||||
60
backend/app/routers/reports.py
Normal file
60
backend/app/routers/reports.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from datetime import datetime, timedelta
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.bet import Bet
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
def _build_report(bets):
|
||||
total = len(bets)
|
||||
wins = sum(1 for b in bets if b.result == "win")
|
||||
losses = sum(1 for b in bets if b.result == "loss")
|
||||
profit = sum(float(b.profit or 0) for b in bets)
|
||||
staked = sum(float(b.amount) for b in bets)
|
||||
|
||||
by_sport = {}
|
||||
by_hour = {}
|
||||
daily_pl = {}
|
||||
for b in bets:
|
||||
s = b.sport or "other"
|
||||
by_sport.setdefault(s, {"count": 0, "profit": 0})
|
||||
by_sport[s]["count"] += 1
|
||||
by_sport[s]["profit"] += float(b.profit or 0)
|
||||
|
||||
h = b.created_at.hour
|
||||
by_hour.setdefault(h, {"count": 0, "profit": 0})
|
||||
by_hour[h]["count"] += 1
|
||||
by_hour[h]["profit"] += float(b.profit or 0)
|
||||
|
||||
day = b.created_at.strftime("%Y-%m-%d")
|
||||
daily_pl.setdefault(day, 0)
|
||||
daily_pl[day] += float(b.profit or 0)
|
||||
|
||||
return {
|
||||
"total_bets": total, "wins": wins, "losses": losses,
|
||||
"total_profit": round(profit, 2), "total_staked": round(staked, 2),
|
||||
"win_rate": round(wins / (wins + losses) * 100, 1) if (wins + losses) > 0 else 0,
|
||||
"roi": round(profit / staked * 100, 1) if staked > 0 else 0,
|
||||
"by_sport": by_sport, "by_hour": {str(k): v for k, v in sorted(by_hour.items())},
|
||||
"daily_pl": daily_pl
|
||||
}
|
||||
|
||||
@router.get("/weekly")
|
||||
async def weekly_report(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
since = datetime.utcnow() - timedelta(days=7)
|
||||
bets = (await db.execute(
|
||||
select(Bet).where(Bet.user_id == user.id, Bet.created_at >= since)
|
||||
)).scalars().all()
|
||||
return _build_report(bets)
|
||||
|
||||
@router.get("/monthly")
|
||||
async def monthly_report(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
since = datetime.utcnow() - timedelta(days=30)
|
||||
bets = (await db.execute(
|
||||
select(Bet).where(Bet.user_id == user.id, Bet.created_at >= since)
|
||||
)).scalars().all()
|
||||
return _build_report(bets)
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
40
backend/app/services/gamification.py
Normal file
40
backend/app/services/gamification.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.models.achievement import Achievement, UserAchievement
|
||||
from app.models.bet import Bet
|
||||
from app.models.lesson import UserLesson
|
||||
|
||||
async def check_achievements(user_id, db: AsyncSession):
|
||||
"""Check and award new achievements"""
|
||||
achievements = (await db.execute(select(Achievement))).scalars().all()
|
||||
existing = (await db.execute(
|
||||
select(UserAchievement.achievement_id).where(UserAchievement.user_id == user_id)
|
||||
)).scalars().all()
|
||||
existing_ids = set(existing)
|
||||
|
||||
bet_count = (await db.execute(
|
||||
select(func.count()).select_from(Bet).where(Bet.user_id == user_id)
|
||||
)).scalar() or 0
|
||||
|
||||
lesson_count = (await db.execute(
|
||||
select(func.count()).select_from(UserLesson).where(UserLesson.user_id == user_id)
|
||||
)).scalar() or 0
|
||||
|
||||
new_badges = []
|
||||
for a in achievements:
|
||||
if a.id in existing_ids:
|
||||
continue
|
||||
awarded = False
|
||||
if a.requirement_type == "bets" and bet_count >= (a.requirement_value or 1):
|
||||
awarded = True
|
||||
elif a.requirement_type == "lessons" and lesson_count >= (a.requirement_value or 1):
|
||||
awarded = True
|
||||
|
||||
if awarded:
|
||||
ua = UserAchievement(user_id=user_id, achievement_id=a.id)
|
||||
db.add(ua)
|
||||
new_badges.append(a.name)
|
||||
|
||||
if new_badges:
|
||||
await db.commit()
|
||||
return new_badges
|
||||
79
backend/app/services/risk_engine.py
Normal file
79
backend/app/services/risk_engine.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.models.bet import Bet
|
||||
from app.models.bankroll import Bankroll
|
||||
|
||||
async def calculate_risk_score(user_id, db: AsyncSession) -> dict:
|
||||
score = 0
|
||||
factors = []
|
||||
now = datetime.utcnow()
|
||||
|
||||
# 1. Horário noturno (22h-5h) +15
|
||||
hour = now.hour
|
||||
if hour >= 22 or hour < 5:
|
||||
score += 15
|
||||
factors.append("Apostando em horário noturno")
|
||||
|
||||
# 2. Valor crescente +25
|
||||
recent = await db.execute(
|
||||
select(Bet).where(Bet.user_id == user_id).order_by(Bet.created_at.desc()).limit(5)
|
||||
)
|
||||
recent_bets = recent.scalars().all()
|
||||
if len(recent_bets) >= 3:
|
||||
amounts = [float(b.amount) for b in recent_bets[:3]]
|
||||
if amounts[0] > amounts[1] > amounts[2]:
|
||||
score += 25
|
||||
factors.append("Valores de aposta crescentes")
|
||||
|
||||
# 3. Recuperação (aposta logo após loss) +30
|
||||
if len(recent_bets) >= 2:
|
||||
last = recent_bets[0]
|
||||
prev = recent_bets[1]
|
||||
if prev.result == "loss" and last.created_at and prev.created_at:
|
||||
diff = (last.created_at - prev.created_at).total_seconds()
|
||||
if diff < 1800: # 30 min
|
||||
score += 30
|
||||
factors.append("Tentativa de recuperação após perda")
|
||||
|
||||
# 4. Frequência alta (>5 apostas em 24h) +20
|
||||
day_ago = now - timedelta(hours=24)
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(Bet).where(Bet.user_id == user_id, Bet.created_at >= day_ago)
|
||||
)
|
||||
bet_count = count_result.scalar() or 0
|
||||
if bet_count > 5:
|
||||
score += 20
|
||||
factors.append(f"Alta frequência: {bet_count} apostas em 24h")
|
||||
|
||||
# 5. Limite estourado +25
|
||||
br_result = await db.execute(select(Bankroll).where(Bankroll.user_id == user_id))
|
||||
bankroll = br_result.scalar_one_or_none()
|
||||
if bankroll and float(bankroll.month_spent) > float(bankroll.monthly_budget):
|
||||
score += 25
|
||||
factors.append("Limite mensal ultrapassado")
|
||||
|
||||
# 6. Emoção negativa +15
|
||||
if recent_bets and recent_bets[0].emotion in ["😤", "😰", "tilt", "ansioso", "frustrado"]:
|
||||
score += 15
|
||||
factors.append("Emoção negativa detectada")
|
||||
|
||||
# 7. Sequência de perdas +20
|
||||
loss_streak = 0
|
||||
for b in recent_bets:
|
||||
if b.result == "loss":
|
||||
loss_streak += 1
|
||||
else:
|
||||
break
|
||||
if loss_streak >= 3:
|
||||
score += 20
|
||||
factors.append(f"Sequência de {loss_streak} perdas consecutivas")
|
||||
|
||||
if score <= 30:
|
||||
level = "green"
|
||||
elif score <= 60:
|
||||
level = "yellow"
|
||||
else:
|
||||
level = "red"
|
||||
|
||||
return {"score": score, "level": level, "factors": factors}
|
||||
0
backend/app/utils/__init__.py
Normal file
0
backend/app/utils/__init__.py
Normal file
41
backend/app/utils/auth.py
Normal file
41
backend/app/utils/auth.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)) -> User:
|
||||
credentials_exception = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
user_id: str = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
Reference in New Issue
Block a user