feat: Implementar funcionalidades de Tarefas e Saúde

- Criadas APIs para Health (métricas de saúde)
  * Registrar peso, altura, % gordura, medidas
  * Histórico completo de medições
  * Estatísticas e resumo

- Criadas APIs para Tasks (tarefas)
  * Criar, editar e deletar tarefas
  * Filtros por status e data
  * Estatísticas detalhadas
  * Prioridades (baixa, média, alta)

- Frontend implementado:
  * Página Health.tsx - registro de métricas
  * Página Tasks.tsx - gerenciamento de tarefas
  * Página Progress.tsx - visualização de progresso
  * Dashboard integrado com estatísticas reais

- Schemas e modelos atualizados
- Todas as funcionalidades testadas e operacionais
This commit is contained in:
Sergio Correa
2025-11-22 02:33:15 +00:00
commit f50174f898
68 changed files with 6835 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
from . import auth, habits, health, messages, admin, tasks
__all__ = ['auth', 'habits', 'health', 'messages', 'admin', 'tasks']

148
backend/app/api/admin.py Normal file
View File

@@ -0,0 +1,148 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime
from app.core.database import get_db
from app.core.security import decode_access_token, get_password_hash
from app.models.user import User
from app.schemas.admin import UserListResponse, UserUpdateRequest, PasswordChangeRequest
router = APIRouter(prefix="/admin", tags=["admin"])
security = HTTPBearer()
def get_current_superadmin(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""Verifica se usuário é superadmin"""
token = credentials.credentials
payload = decode_access_token(token)
if not payload:
raise HTTPException(status_code=401, detail="Token inválido")
user_id = payload.get("sub")
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_superadmin:
raise HTTPException(
status_code=403,
detail="Acesso negado. Apenas superadmin."
)
return user
@router.get("/users", response_model=List[UserListResponse])
async def list_all_users(
admin: User = Depends(get_current_superadmin),
db: Session = Depends(get_db)
):
"""Listar todos os usuários (apenas superadmin)"""
users = db.query(User).order_by(User.created_at.desc()).all()
return users
@router.get("/stats")
async def get_admin_stats(
admin: User = Depends(get_current_superadmin),
db: Session = Depends(get_db)
):
"""Estatísticas gerais do sistema"""
from sqlalchemy import func
from app.models.habit import Habit, HabitCompletion
total_users = db.query(func.count(User.id)).scalar()
active_users = db.query(func.count(User.id)).filter(User.is_active == True).scalar()
total_habits = db.query(func.count(Habit.id)).scalar()
total_completions = db.query(func.count(HabitCompletion.id)).scalar()
return {
"total_users": total_users,
"active_users": active_users,
"inactive_users": total_users - active_users,
"total_habits": total_habits,
"total_completions": total_completions
}
@router.patch("/users/{user_id}")
async def update_user(
user_id: str,
data: UserUpdateRequest,
admin: User = Depends(get_current_superadmin),
db: Session = Depends(get_db)
):
"""Atualizar dados de usuário"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Usuário não encontrado")
# Atualizar campos
if data.email is not None:
# Verificar se email já existe
existing = db.query(User).filter(
User.email == data.email,
User.id != user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Email já em uso")
user.email = data.email
if data.username is not None:
# Verificar se username já existe
existing = db.query(User).filter(
User.username == data.username,
User.id != user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Username já em uso")
user.username = data.username
if data.full_name is not None:
user.full_name = data.full_name
if data.is_active is not None:
user.is_active = data.is_active
if data.is_verified is not None:
user.is_verified = data.is_verified
db.commit()
db.refresh(user)
return {"message": "Usuário atualizado", "user": user}
@router.delete("/users/{user_id}")
async def delete_user(
user_id: str,
admin: User = Depends(get_current_superadmin),
db: Session = Depends(get_db)
):
"""Deletar usuário (soft delete - apenas desativa)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Usuário não encontrado")
if user.is_superadmin:
raise HTTPException(status_code=403, detail="Não é possível deletar superadmin")
user.is_active = False
db.commit()
return {"message": "Usuário desativado com sucesso"}
@router.post("/users/{user_id}/reset-password")
async def reset_user_password(
user_id: str,
data: PasswordChangeRequest,
admin: User = Depends(get_current_superadmin),
db: Session = Depends(get_db)
):
"""Resetar senha de usuário"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Usuário não encontrado")
user.password_hash = get_password_hash(data.new_password)
db.commit()
return {"message": "Senha alterada com sucesso"}

56
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,56 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import get_password_hash, verify_password, create_access_token
from app.models.user import User
from app.schemas.user import UserCreate, UserLogin, Token, UserResponse
from datetime import datetime
router = APIRouter()
@router.post("/register", response_model=Token)
def register(user: UserCreate, db: Session = Depends(get_db)):
if db.query(User).filter(User.email == user.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
if db.query(User).filter(User.username == user.username).first():
raise HTTPException(status_code=400, detail="Username already taken")
db_user = User(
email=user.email,
username=user.username,
password_hash=get_password_hash(user.password),
full_name=user.full_name,
phone=user.phone,
is_verified=True # SEM verificação de email
)
db.add(db_user)
db.commit()
db.refresh(db_user)
access_token = create_access_token(data={"sub": db_user.email})
return {
"access_token": access_token,
"token_type": "bearer",
"user": UserResponse.from_orm(db_user)
}
@router.post("/login", response_model=Token)
def login(credentials: UserLogin, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == credentials.email).first()
if not user or not verify_password(credentials.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
user.last_login_at = datetime.utcnow()
db.commit()
access_token = create_access_token(data={"sub": user.email})
return {
"access_token": access_token,
"token_type": "bearer",
"user": UserResponse.from_orm(user)
}

155
backend/app/api/habits.py Normal file
View File

@@ -0,0 +1,155 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from typing import List
from datetime import date, datetime
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.user import User
from app.models.habit import Habit, HabitCompletion
from app.schemas.habit import HabitCreate, HabitResponse, HabitCompletionCreate
router = APIRouter(prefix="/habits", tags=["habits"])
security = HTTPBearer()
def get_current_user_id(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
"""Extrai user_id do token JWT"""
token = credentials.credentials
payload = decode_access_token(token)
if not payload:
raise HTTPException(status_code=401, detail="Token inválido")
return payload.get("sub")
@router.post("/", response_model=HabitResponse, status_code=status.HTTP_201_CREATED)
async def create_habit(
habit_data: HabitCreate,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Criar novo hábito"""
new_habit = Habit(
user_id=user_id,
name=habit_data.name,
description=habit_data.description,
frequency_type=habit_data.frequency_type,
target_count=habit_data.target_count,
reminder_time=habit_data.reminder_time,
start_date=habit_data.start_date or date.today()
)
db.add(new_habit)
db.commit()
db.refresh(new_habit)
return new_habit
@router.get("/", response_model=List[HabitResponse])
async def list_habits(
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Listar todos os hábitos do usuário"""
habits = db.query(Habit).filter(
Habit.user_id == user_id,
Habit.is_active == True
).all()
return habits
@router.post("/{habit_id}/complete", status_code=status.HTTP_201_CREATED)
async def complete_habit(
habit_id: str,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Marcar hábito como completo para hoje"""
# Verificar se já completou hoje
today = date.today()
existing = db.query(HabitCompletion).filter(
HabitCompletion.habit_id == habit_id,
HabitCompletion.user_id == user_id,
HabitCompletion.completion_date == today
).first()
if existing:
raise HTTPException(status_code=400, detail="Hábito já completado hoje")
# Criar completion
completion = HabitCompletion(
habit_id=habit_id,
user_id=user_id,
completion_date=today,
completion_time=datetime.now().time()
)
db.add(completion)
db.commit()
return {"message": "Hábito completado!", "date": today}
@router.delete("/{habit_id}/complete")
async def uncomplete_habit(
habit_id: str,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Desmarcar hábito de hoje"""
today = date.today()
completion = db.query(HabitCompletion).filter(
HabitCompletion.habit_id == habit_id,
HabitCompletion.user_id == user_id,
HabitCompletion.completion_date == today
).first()
if not completion:
raise HTTPException(status_code=404, detail="Completion não encontrado")
db.delete(completion)
db.commit()
return {"message": "Completion removido"}
@router.get("/stats")
async def get_habit_stats(
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Estatísticas de hábitos"""
from sqlalchemy import func
total_habits = db.query(func.count(Habit.id)).filter(
Habit.user_id == user_id,
Habit.is_active == True
).scalar()
completed_today = db.query(func.count(HabitCompletion.id)).filter(
HabitCompletion.user_id == user_id,
HabitCompletion.completion_date == date.today()
).scalar()
return {
"total_habits": total_habits,
"completed_today": completed_today,
"pending_today": total_habits - completed_today
}
@router.delete("/{habit_id}")
async def delete_habit(
habit_id: str,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Deletar hábito"""
habit = db.query(Habit).filter(
Habit.id == habit_id,
Habit.user_id == user_id
).first()
if not habit:
raise HTTPException(status_code=404, detail="Hábito não encontrado")
db.delete(habit)
db.commit()
return {"message": "Hábito deletado"}

169
backend/app/api/health.py Normal file
View File

@@ -0,0 +1,169 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from typing import List
from datetime import date
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.health import HealthMetric
from app.schemas.health import HealthMetricCreate, HealthMetricResponse, HealthMetricUpdate
router = APIRouter()
security = HTTPBearer()
def get_current_user_id(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
"""Extrai user_id do token JWT"""
token = credentials.credentials
payload = decode_access_token(token)
if not payload:
raise HTTPException(status_code=401, detail="Token inválido")
return payload.get("sub")
@router.post("/", response_model=HealthMetricResponse, status_code=status.HTTP_201_CREATED)
async def create_health_metric(
metric_data: HealthMetricCreate,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Criar nova métrica de saúde"""
new_metric = HealthMetric(
user_id=user_id,
measurement_date=metric_data.measurement_date,
measurement_time=metric_data.measurement_time,
weight=metric_data.weight,
height=metric_data.height,
body_fat_percentage=metric_data.body_fat_percentage,
muscle_mass=metric_data.muscle_mass,
waist=metric_data.waist,
chest=metric_data.chest,
hips=metric_data.hips,
notes=metric_data.notes
)
db.add(new_metric)
db.commit()
db.refresh(new_metric)
return new_metric
@router.get("/", response_model=List[HealthMetricResponse])
async def list_health_metrics(
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db),
limit: int = 100
):
"""Listar todas as métricas de saúde do usuário"""
metrics = db.query(HealthMetric).filter(
HealthMetric.user_id == user_id
).order_by(HealthMetric.measurement_date.desc()).limit(limit).all()
return metrics
@router.get("/latest", response_model=HealthMetricResponse)
async def get_latest_metric(
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Obter última métrica registrada"""
metric = db.query(HealthMetric).filter(
HealthMetric.user_id == user_id
).order_by(HealthMetric.measurement_date.desc()).first()
if not metric:
raise HTTPException(status_code=404, detail="Nenhuma métrica encontrada")
return metric
@router.get("/{metric_id}", response_model=HealthMetricResponse)
async def get_health_metric(
metric_id: str,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Obter métrica específica"""
metric = db.query(HealthMetric).filter(
HealthMetric.id == metric_id,
HealthMetric.user_id == user_id
).first()
if not metric:
raise HTTPException(status_code=404, detail="Métrica não encontrada")
return metric
@router.put("/{metric_id}", response_model=HealthMetricResponse)
async def update_health_metric(
metric_id: str,
metric_data: HealthMetricUpdate,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Atualizar métrica de saúde"""
metric = db.query(HealthMetric).filter(
HealthMetric.id == metric_id,
HealthMetric.user_id == user_id
).first()
if not metric:
raise HTTPException(status_code=404, detail="Métrica não encontrada")
# Atualizar campos
update_data = metric_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(metric, field, value)
db.commit()
db.refresh(metric)
return metric
@router.delete("/{metric_id}")
async def delete_health_metric(
metric_id: str,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Deletar métrica de saúde"""
metric = db.query(HealthMetric).filter(
HealthMetric.id == metric_id,
HealthMetric.user_id == user_id
).first()
if not metric:
raise HTTPException(status_code=404, detail="Métrica não encontrada")
db.delete(metric)
db.commit()
return {"message": "Métrica deletada com sucesso"}
@router.get("/stats/summary")
async def get_health_stats(
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Estatísticas e resumo de saúde"""
latest = db.query(HealthMetric).filter(
HealthMetric.user_id == user_id
).order_by(HealthMetric.measurement_date.desc()).first()
# Pegar primeira e última medição para calcular diferença
first = db.query(HealthMetric).filter(
HealthMetric.user_id == user_id,
HealthMetric.weight.isnot(None)
).order_by(HealthMetric.measurement_date.asc()).first()
stats = {
"current_weight": float(latest.weight) if latest and latest.weight else None,
"current_height": float(latest.height) if latest and latest.height else None,
"current_body_fat": float(latest.body_fat_percentage) if latest and latest.body_fat_percentage else None,
"weight_change": None,
"total_measurements": db.query(HealthMetric).filter(HealthMetric.user_id == user_id).count()
}
if latest and first and latest.weight and first.weight:
stats["weight_change"] = float(latest.weight - first.weight)
return stats

View File

@@ -0,0 +1,93 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import Dict
from app.core.database import get_db
from app.services.message_service import MessageService
router = APIRouter(prefix="/messages", tags=["messages"])
@router.get("/daily", response_model=Dict)
async def get_daily_message(
user_id: str, # TODO: Pegar do JWT token quando implementar autenticação
db: Session = Depends(get_db)
):
"""
Retorna a mensagem motivacional do dia para o usuário.
A mensagem é contextual baseada em:
- Streaks e conquistas
- Inatividade
- Lembretes
- Progresso
- Hora do dia
"""
try:
message_service = MessageService(db)
message = message_service.get_message_of_the_day(user_id)
return {
"success": True,
"message": message
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/click/{message_id}")
async def mark_message_clicked(
message_id: str,
user_id: str,
db: Session = Depends(get_db)
):
"""Marca mensagem como clicada pelo usuário"""
from app.models.message import UserMessageLog
from datetime import datetime
try:
log = db.query(UserMessageLog).filter(
UserMessageLog.id == message_id,
UserMessageLog.user_id == user_id
).first()
if log:
log.was_clicked = True
log.clicked_at = datetime.now()
db.commit()
return {"success": True, "message": "Click registrado"}
else:
raise HTTPException(status_code=404, detail="Mensagem não encontrada")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/history")
async def get_message_history(
user_id: str,
limit: int = 10,
db: Session = Depends(get_db)
):
"""Retorna histórico de mensagens do usuário"""
from app.models.message import UserMessageLog
try:
logs = db.query(UserMessageLog).filter(
UserMessageLog.user_id == user_id
).order_by(UserMessageLog.shown_at.desc()).limit(limit).all()
return {
"success": True,
"messages": [
{
"id": str(log.id),
"message_text": log.message_text,
"message_type": log.message_type,
"shown_at": log.shown_at.isoformat(),
"was_clicked": log.was_clicked
}
for log in logs
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

218
backend/app/api/tasks.py Normal file
View File

@@ -0,0 +1,218 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import date
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.task import Task
from app.schemas.task import TaskCreate, TaskResponse, TaskUpdate
router = APIRouter()
security = HTTPBearer()
def get_current_user_id(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
"""Extrai user_id do token JWT"""
token = credentials.credentials
payload = decode_access_token(token)
if not payload:
raise HTTPException(status_code=401, detail="Token inválido")
return payload.get("sub")
@router.post("/", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
async def create_task(
task_data: TaskCreate,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Criar nova tarefa"""
new_task = Task(
user_id=user_id,
title=task_data.title,
description=task_data.description,
priority=task_data.priority,
status=task_data.status,
due_date=task_data.due_date,
due_time=task_data.due_time,
category_id=task_data.category_id
)
db.add(new_task)
db.commit()
db.refresh(new_task)
return new_task
@router.get("/", response_model=List[TaskResponse])
async def list_tasks(
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db),
status_filter: Optional[str] = None,
include_archived: bool = False
):
"""Listar todas as tarefas do usuário"""
query = db.query(Task).filter(Task.user_id == user_id)
if not include_archived:
query = query.filter(Task.is_archived == False)
if status_filter:
query = query.filter(Task.status == status_filter)
tasks = query.order_by(Task.due_date.asc()).all()
return tasks
@router.get("/today", response_model=List[TaskResponse])
async def get_today_tasks(
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Obter tarefas de hoje"""
today = date.today()
tasks = db.query(Task).filter(
Task.user_id == user_id,
Task.due_date == today,
Task.is_archived == False
).order_by(Task.due_time.asc()).all()
return tasks
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(
task_id: str,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Obter tarefa específica"""
task = db.query(Task).filter(
Task.id == task_id,
Task.user_id == user_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Tarefa não encontrada")
return task
@router.put("/{task_id}", response_model=TaskResponse)
async def update_task(
task_id: str,
task_data: TaskUpdate,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Atualizar tarefa"""
task = db.query(Task).filter(
Task.id == task_id,
Task.user_id == user_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Tarefa não encontrada")
# Atualizar campos
update_data = task_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(task, field, value)
db.commit()
db.refresh(task)
return task
@router.patch("/{task_id}/status")
async def update_task_status(
task_id: str,
status: str,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Atualizar status da tarefa"""
task = db.query(Task).filter(
Task.id == task_id,
Task.user_id == user_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Tarefa não encontrada")
task.status = status
db.commit()
return {"message": "Status atualizado", "status": status}
@router.delete("/{task_id}")
async def delete_task(
task_id: str,
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Deletar tarefa"""
task = db.query(Task).filter(
Task.id == task_id,
Task.user_id == user_id
).first()
if not task:
raise HTTPException(status_code=404, detail="Tarefa não encontrada")
db.delete(task)
db.commit()
return {"message": "Tarefa deletada com sucesso"}
@router.get("/stats/summary")
async def get_task_stats(
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db)
):
"""Estatísticas de tarefas"""
from sqlalchemy import func
total_tasks = db.query(func.count(Task.id)).filter(
Task.user_id == user_id,
Task.is_archived == False
).scalar()
pending = db.query(func.count(Task.id)).filter(
Task.user_id == user_id,
Task.status == "pending",
Task.is_archived == False
).scalar()
in_progress = db.query(func.count(Task.id)).filter(
Task.user_id == user_id,
Task.status == "in_progress",
Task.is_archived == False
).scalar()
completed = db.query(func.count(Task.id)).filter(
Task.user_id == user_id,
Task.status == "completed",
Task.is_archived == False
).scalar()
today = date.today()
today_tasks = db.query(func.count(Task.id)).filter(
Task.user_id == user_id,
Task.due_date == today,
Task.is_archived == False
).scalar()
today_completed = db.query(func.count(Task.id)).filter(
Task.user_id == user_id,
Task.due_date == today,
Task.status == "completed"
).scalar()
return {
"total_tasks": total_tasks,
"pending": pending,
"in_progress": in_progress,
"completed": completed,
"today_tasks": today_tasks,
"today_completed": today_completed
}