feat: add backend FastAPI structure and Supabase schema

This commit is contained in:
Alan Alonso
2026-05-23 00:41:13 -06:00
parent 17cdde7dbb
commit e6eb466c14
38 changed files with 1760 additions and 0 deletions

View File

21
server/.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info
dist
build
.env
data/*.db
data/*.db-journal
logs/*.log
.git
.gitignore
README.md
.vscode
.idea
venv
.venv

7
server/.env Normal file
View File

@@ -0,0 +1,7 @@
SECRET_KEY=mi-clave-secreta-super-segura-cambiar-en-produccion-123456
DEBUG=true
DATABASE_PATH=/data/basura.db
SIM_TICK_SECONDS=10
SIM_ETA_ALERT_MINUTES=10
SUPABASE_URL=https://qckndtzudciejpnwqfzt.supabase.co
SUPABASE_SERVICE_KEY=sb_secret_2y3a_9qD5nRtZl-41CY-jw_LA-smvxC

5
server/.env.example Normal file
View File

@@ -0,0 +1,5 @@
SECRET_KEY=your-super-secret-key-change-this-in-production
DEBUG=true
DATABASE_PATH=/data/basura.db
SIM_TICK_SECONDS=10
SIM_ETA_ALERT_MINUTES=10

0
server/__init__.py Normal file
View File

0
server/app/__init__.py Normal file
View File

View File

@@ -0,0 +1,3 @@
from app.api.routes.eta_router import router
__all__ = ["router"]

View File

@@ -0,0 +1,3 @@
from app.api.routes.eta_router import router
__all__ = ["router"]

View File

@@ -0,0 +1,43 @@
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from app.db.database import get_connection
from app.core.dependencies import get_current_user
router = APIRouter()
class AddressCreate(BaseModel):
lat: float
lng: float
alias: Optional[str] = None
address_text: str
@router.post("/addresses")
async def create_address(
address: AddressCreate,
current_user: dict = Depends(get_current_user)
):
# Determine route based on location (simplified - in production use PostGIS)
route_id = "RUTA-01" # Mock: should calculate based on lat/lng
conn = get_connection()
cursor = conn.execute(
"INSERT INTO addresses (user_id, alias, lat, lng, route_id) VALUES (?, ?, ?, ?, ?) RETURNING id",
(current_user["id"], address.alias, address.lat, address.lng, route_id)
)
address_id = cursor.fetchone()[0]
conn.commit()
conn.close()
return {"id": address_id, "route_id": route_id}
@router.get("/addresses")
async def get_addresses(current_user: dict = Depends(get_current_user)):
conn = get_connection()
addresses = conn.execute(
"SELECT id, alias, lat, lng, route_id FROM addresses WHERE user_id = ?",
(current_user["id"],)
).fetchall()
conn.close()
return [dict(addr) for addr in addresses]

View File

@@ -0,0 +1,77 @@
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Depends, status
from pydantic import BaseModel, EmailStr
import jwt
from passlib.context import CryptContext
from app.core.config import settings
from app.db.database import get_connection
router = APIRouter()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class UserRegister(BaseModel):
email: EmailStr
phone: str | None = None
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
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_token(user_id: int) -> str:
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": str(user_id), "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
@router.post("/register", response_model=TokenResponse)
async def register(user: UserRegister):
conn = get_connection()
existing = conn.execute(
"SELECT id FROM users WHERE email = ?", (user.email,)
).fetchone()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
password_hash = hash_password(user.password)
cursor = conn.execute(
"INSERT INTO users (email, phone, password_hash) VALUES (?, ?, ?) RETURNING id",
(user.email, user.phone, password_hash)
)
user_id = cursor.fetchone()[0]
# Create default preferences
conn.execute(
"INSERT INTO notification_preferences (user_id) VALUES (?)",
(user_id,)
)
conn.commit()
conn.close()
token = create_token(user_id)
return TokenResponse(access_token=token)
@router.post("/login", response_model=TokenResponse)
async def login(user: UserLogin):
conn = get_connection()
db_user = conn.execute(
"SELECT id, password_hash FROM users WHERE email = ?",
(user.email,)
).fetchone()
conn.close()
if not db_user or not verify_password(user.password, db_user[1]):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_token(db_user[0])
return TokenResponse(access_token=token)

View File

@@ -0,0 +1,154 @@
"""
Endpoints del Módulo B con caching
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Depends
from pydantic import BaseModel
from app.data.repositories.ruta_repository import SQLiteRutaRepository
from app.services.simulador import obtener_simulador
from app.services.ws_manager import ws_manager
from app.core.cache import cached, cache_client, invalidate_route_cache
from app.core.dependencies import get_current_user
from app.db.database import get_connection
router = APIRouter()
logger = logging.getLogger(__name__)
def _repo() -> SQLiteRutaRepository:
return SQLiteRutaRepository()
# ── GET /eta/{address_id} con caché ─────────────────────────────────────────
@router.get("/eta/{address_id}", summary="Ventana ETA para un domicilio")
@cached(prefix="eta", ttl=30)
async def get_eta(
address_id: int,
current_user: dict = Depends(get_current_user)
):
"""
Devuelve ETA + ventana horaria solo para el domicilio solicitado.
Cacheado por 30 segundos para evitar consultas repetidas.
"""
# Verificar que el domicilio pertenece al usuario (RBAC)
conn = get_connection()
addr = conn.execute(
"SELECT user_id FROM addresses WHERE id = ?", (address_id,)
).fetchone()
conn.close()
if not addr or addr["user_id"] != current_user["id"]:
raise HTTPException(status_code=403, detail="No autorizado")
resultado = _repo().calcular_eta(address_id)
if not resultado:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
return {
"address_id": resultado.address_id,
"route_id": resultado.route_id,
"status": resultado.status,
"eta_minutos": resultado.eta_minutos,
"ventana": {
"inicio": resultado.ventana_inicio,
"fin": resultado.ventana_fin,
},
"mensaje": resultado.mensaje,
"cached": False,
}
# ── WS /ws/{address_id} ───────────────────────────────────────────────
@router.websocket("/ws/{address_id}")
async def websocket_address(websocket: WebSocket, address_id: int):
"""WebSocket para recibir notificaciones en tiempo real"""
zona_key = str(address_id)
await ws_manager.conectar(websocket, zona_key)
logger.info(f"[WS] Cliente conectado — address_id={address_id}")
try:
while True:
await websocket.receive_text() # mantener vivo
except WebSocketDisconnect:
ws_manager.desconectar(websocket, zona_key)
logger.info(f"[WS] Cliente desconectado — address_id={address_id}")
# ── POST /alerts/breakdown ────────────────────────────────────────────
class BreakdownPayload(BaseModel):
route_id: str
mensaje: Optional[str] = "El camión reportó una falla mecánica."
@router.post("/alerts/breakdown", summary="Reportar avería de camión")
async def reportar_averia(payload: BreakdownPayload):
"""Endpoint para reportar avería - invalida caché automáticamente"""
sim = obtener_simulador(payload.route_id)
await sim.forzar_averia(payload.mensaje)
# Invalidar caché de esta ruta
await invalidate_route_cache(payload.route_id)
return {
"ok": True,
"route_id": payload.route_id,
"mensaje": "Avería registrada y usuarios notificados",
}
# ── Admin / Demo ──────────────────────────────────────────────────────
@router.post("/admin/route/{route_id}/start", summary="Iniciar simulación")
async def iniciar_ruta(route_id: str):
"""Iniciar el simulador de camión"""
obtener_simulador(route_id).iniciar()
await invalidate_route_cache(route_id)
return {"ok": True, "mensaje": f"Simulador {route_id} iniciado"}
@router.post("/admin/route/{route_id}/delay", summary="Forzar retraso")
async def forzar_retraso(route_id: str, mensaje: str = "El camión reportó un retraso."):
"""Forzar retraso en la ruta"""
await obtener_simulador(route_id).forzar_retraso(mensaje)
await invalidate_route_cache(route_id)
return {"ok": True, "mensaje": "Retraso notificado"}
@router.get("/admin/route/{route_id}/status", summary="Estado interno del camión")
async def estado_ruta(route_id: str):
"""Obtener estado actual del camión"""
repo = _repo()
ts = repo.obtener_truck_status(route_id)
if not ts:
raise HTTPException(status_code=404, detail="Ruta no encontrada")
return {
"route_id": ts.route_id,
"current_position_id": ts.current_position_id,
"status": ts.status.value,
"last_update": ts.last_update.isoformat(),
"ws_clientes_activos": ws_manager.zonas_activas(),
}
# ── Endpoint para limpiar caché (admin) ──────────────────────────────
@router.post("/admin/cache/clear", summary="Limpiar toda la caché")
async def clear_cache():
"""Limpiar caché de Redis y memoria"""
await cache_client.clear_all()
return {"ok": True, "mensaje": "Caché limpiada"}
@router.get("/admin/cache/stats", summary="Estadísticas de caché")
async def cache_stats():
"""Estadísticas del sistema de caché"""
return {
"enabled": cache_client.enabled,
"redis_available": cache_client.redis_client is not None,
"memory_cache_size": len(cache_client.memory_cache),
}

View File

@@ -0,0 +1,82 @@
"""
Guía de separación de residuos - endpoint cacheado
"""
from fastapi import APIRouter
from app.core.cache import cached
router = APIRouter()
# Guía de separación (cache por 24 horas)
RECYCLING_GUIDE = {
"categories": [
{
"name": "Orgánico",
"color": "#4CAF50",
"icon": "leaf",
"items": [
"Restos de comida",
"Cáscaras de fruta",
"Hojas y césped",
"Cáscaras de huevo",
"Café y filtros de papel",
"Servilletas de papel",
"Restos de poda"
]
},
{
"name": "Reciclable",
"color": "#2196F3",
"icon": "recycle",
"items": [
"Plástico (PET, HDPE, PP)",
"Vidrio (botellas, frascos)",
"Papel y cartón (limpio y seco)",
"Latas de aluminio",
"Envases Tetra Pak",
"Periódicos y revistas"
]
},
{
"name": "Sanitario",
"color": "#9C27B0",
"icon": "medical-services",
"items": [
"Pañales desechables",
"Toallas sanitarias",
"Papel higiénico usado",
"Algodón y gasas",
"Cubrebocas",
"Jeringas (en contenedor especial)"
]
},
{
"name": "Peligroso",
"color": "#F44336",
"icon": "warning",
"items": [
"Pilas y baterías",
"Aceite de cocina usado",
"Pinturas y solventes",
"Químicos de limpieza",
"Medicamentos caducados",
"Focos y fluorescentes",
"Electrónicos"
]
}
],
"tips": [
"Lava los envases reciclables antes de desecharlos",
"No mezcles residuos peligrosos con la basura común",
"Los residuos sanitarios deben ir en bolsa aparte",
"El aceite de cocina debe almacenarse en botella cerrada"
]
}
@router.get("/recycling-guide")
@cached(prefix="guide", ttl=86400) # 24 horas de caché
async def get_recycling_guide():
"""
Guía de separación de residuos.
Funciona offline en el cliente (cacheable por 24 horas).
"""
return RECYCLING_GUIDE

View File

@@ -0,0 +1,3 @@
from app.core.config import settings
__all__ = ["settings"]

198
server/app/core/cache.py Normal file
View File

@@ -0,0 +1,198 @@
"""
Sistema de caching con Redis y memoria
"""
import json
import hashlib
import logging
from functools import wraps
from typing import Optional, Any, Callable
from datetime import datetime, timedelta
import redis
from redis.exceptions import RedisError
from app.core.config import settings
logger = logging.getLogger(__name__)
class CacheClient:
"""Cliente unificado de caché"""
def __init__(self):
self.redis_client = None
self.memory_cache = {} # Fallback en memoria
self.enabled = settings.cache_enabled
if self.enabled:
try:
self.redis_client = redis.Redis(
host=settings.redis_host,
port=settings.redis_port,
db=settings.redis_db,
password=settings.redis_password,
decode_responses=True,
socket_connect_timeout=5,
socket_timeout=5
)
# Probar conexión
self.redis_client.ping()
logger.info(f"✅ Redis conectado en {settings.redis_host}:{settings.redis_port}")
except RedisError as e:
logger.warning(f"⚠️ Redis no disponible: {e}. Usando caché en memoria.")
self.redis_client = None
self.enabled = False
def _get_key(self, prefix: str, key: str) -> str:
"""Genera clave con prefijo"""
return f"{prefix}:{key}"
def _hash_key(self, key: str) -> str:
"""Hash de claves largas"""
if len(key) > 100:
return hashlib.md5(key.encode()).hexdigest()
return key
async def get(self, prefix: str, key: str) -> Optional[Any]:
"""Obtener valor del caché"""
if not self.enabled:
return None
cache_key = self._get_key(prefix, self._hash_key(key))
try:
# Intentar Redis primero
if self.redis_client:
value = self.redis_client.get(cache_key)
if value:
return json.loads(value)
# Fallback a memoria
if cache_key in self.memory_cache:
data, expiry = self.memory_cache[cache_key]
if datetime.now() < expiry:
return data
else:
del self.memory_cache[cache_key]
return None
except Exception as e:
logger.error(f"Error reading cache: {e}")
return None
async def set(self, prefix: str, key: str, value: Any, ttl: int = 60):
"""Guardar en caché"""
if not self.enabled:
return
cache_key = self._get_key(prefix, self._hash_key(key))
try:
serialized = json.dumps(value, default=str)
if self.redis_client:
self.redis_client.setex(cache_key, ttl, serialized)
# Guardar también en memoria
self.memory_cache[cache_key] = (value, datetime.now() + timedelta(seconds=ttl))
except Exception as e:
logger.error(f"Error writing cache: {e}")
async def delete(self, prefix: str, key: str):
"""Eliminar del caché"""
if not self.enabled:
return
cache_key = self._get_key(prefix, self._hash_key(key))
try:
if self.redis_client:
self.redis_client.delete(cache_key)
if cache_key in self.memory_cache:
del self.memory_cache[cache_key]
except Exception as e:
logger.error(f"Error deleting cache: {e}")
async def delete_pattern(self, pattern: str):
"""Eliminar por patrón"""
if not self.enabled or not self.redis_client:
return
try:
keys = self.redis_client.keys(pattern)
if keys:
self.redis_client.delete(*keys)
except Exception as e:
logger.error(f"Error deleting pattern: {e}")
async def clear_all(self):
"""Limpiar todo el caché"""
if not self.enabled:
return
try:
if self.redis_client:
self.redis_client.flushdb()
self.memory_cache.clear()
logger.info("Cache cleared")
except Exception as e:
logger.error(f"Error clearing cache: {e}")
# Singleton
cache_client = CacheClient()
def cached(prefix: str, ttl: int = 60, key_builder: Optional[Callable] = None):
"""
Decorador para cachear respuestas de endpoints
Uso:
@cached(prefix="eta", ttl=30)
async def get_eta(address_id: int):
...
"""
def decorator(func: Callable):
@wraps(func)
async def wrapper(*args, **kwargs):
if not cache_client.enabled:
return await func(*args, **kwargs)
# Construir clave
if key_builder:
cache_key = key_builder(*args, **kwargs)
else:
# Usar nombre de función y argumentos
cache_key = f"{func.__name__}:{str(args)}:{str(kwargs)}"
# Intentar obtener del caché
cached_value = await cache_client.get(prefix, cache_key)
if cached_value is not None:
logger.debug(f"Cache HIT: {prefix}:{cache_key}")
return cached_value
# Ejecutar función
result = await func(*args, **kwargs)
# Guardar en caché
if result is not None:
await cache_client.set(prefix, cache_key, result, ttl)
logger.debug(f"Cache MISS: {prefix}:{cache_key} saved")
return result
return wrapper
return decorator
async def invalidate_user_cache(user_id: int):
"""Invalidar caché relacionada con un usuario"""
await cache_client.delete_pattern(f"addresses:user:{user_id}:*")
await cache_client.delete_pattern(f"eta:user:{user_id}:*")
logger.info(f"Cache invalidated for user {user_id}")
async def invalidate_route_cache(route_id: str):
"""Invalidar caché relacionada con una ruta"""
await cache_client.delete_pattern(f"eta:route:{route_id}:*")
await cache_client.delete_pattern(f"truck_status:{route_id}")
logger.info(f"Cache invalidated for route {route_id}")

35
server/app/core/config.py Normal file
View File

@@ -0,0 +1,35 @@
from typing import Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "BasuraApp API"
debug: bool = True
secret_key: str = "CAMBIA_ESTO_EN_PRODUCCION_clave_super_secreta"
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 # 24 horas
# SQLite
database_url: str = "sqlite:///./basura.db"
# Redis
redis_host: str = "localhost"
redis_port: int = 6379
redis_db: int = 0
redis_password: Optional[str] = None
# Cache settings
cache_enabled: bool = True
cache_ttl_eta: int = 30 # 30 segundos para ETA
cache_ttl_addresses: int = 300 # 5 minutos para direcciones
cache_ttl_guide: int = 86400 # 24 horas para guía de reciclaje
# Simulador
sim_tick_seconds: int = 10
sim_eta_alert_minutes: int = 10
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,36 @@
from fastapi import HTTPException, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
from app.core.config import settings
from app.db.database import get_connection
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
token = credentials.credentials
try:
payload = jwt.decode(
token,
settings.secret_key,
algorithms=[settings.algorithm]
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid token")
conn = get_connection()
user = conn.execute(
"SELECT id, email, phone FROM users WHERE id = ?",
(user_id,)
).fetchone()
conn.close()
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return dict(user)
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")

View File

@@ -0,0 +1,3 @@
from app.data.repositories.ruta_repository import SQLiteRutaRepository
__all__ = ["SQLiteRutaRepository"]

View File

View File

@@ -0,0 +1,3 @@
from app.data.repositories.ruta_repository import SQLiteRutaRepository
__all__ = ["SQLiteRutaRepository"]

View File

@@ -0,0 +1,189 @@
"""
Capa de Datos — SQLite con soporte de caché
"""
from datetime import datetime
from typing import Optional
from app.db.database import get_connection
from app.domain.entities.ruta import (
Coordenada, EstadoCamion, ETAResult,
NotificationPreferences, PuntoRuta, Ruta, TruckStatus,
)
class SQLiteRutaRepository:
# ── Rutas y puntos ────────────────────────────────────────────────
def obtener_ruta(self, route_id: str) -> Optional[Ruta]:
conn = get_connection()
row = conn.execute(
"SELECT * FROM rutas WHERE id = ?", (route_id,)
).fetchone()
if not row:
conn.close()
return None
puntos = [
PuntoRuta(
orden=p["orden"],
nombre=p["nombre"],
coordenada=Coordenada(p["lat"], p["lng"]),
tiempo_estimado_min=p["tiempo_estimado_min"],
)
for p in conn.execute(
"SELECT * FROM puntos_ruta WHERE ruta_id=? ORDER BY orden",
(route_id,),
).fetchall()
]
conn.close()
return Ruta(id=row["id"], nombre=row["nombre"],
puntos=puntos, turno=row["turno"])
def obtener_ruta_por_address(self, address_id: int) -> Optional[Ruta]:
conn = get_connection()
row = conn.execute(
"SELECT route_id FROM addresses WHERE id = ?", (address_id,)
).fetchone()
conn.close()
if not row:
return None
return self.obtener_ruta(row["route_id"])
# ── truck_status ──────────────────────────────────────────
def obtener_truck_status(self, route_id: str) -> Optional[TruckStatus]:
conn = get_connection()
row = conn.execute(
"SELECT * FROM truck_status WHERE route_id = ?", (route_id,)
).fetchone()
conn.close()
if not row:
return None
return TruckStatus(
route_id=row["route_id"],
current_position_id=row["current_position_id"],
last_update=datetime.fromisoformat(row["last_update"]),
status=EstadoCamion(row["status"]),
)
def guardar_truck_status(self, ts: TruckStatus) -> None:
conn = get_connection()
conn.execute("""
INSERT INTO truck_status (route_id, current_position_id, last_update, status)
VALUES (?, ?, ?, ?)
ON CONFLICT(route_id) DO UPDATE SET
current_position_id = excluded.current_position_id,
last_update = excluded.last_update,
status = excluded.status
""", (
ts.route_id,
ts.current_position_id,
ts.last_update.isoformat(),
ts.status.value,
))
conn.commit()
conn.close()
# Invalidar caché de esta ruta
from app.core.cache import invalidate_route_cache
import asyncio
asyncio.create_task(invalidate_route_cache(ts.route_id))
# ── Preferencias de notificación ─────────────────────────────────
def obtener_preferencias(self, user_id: int) -> NotificationPreferences:
conn = get_connection()
row = conn.execute(
"SELECT * FROM notification_preferences WHERE user_id = ?",
(user_id,),
).fetchone()
conn.close()
if not row:
return NotificationPreferences(user_id=user_id)
return NotificationPreferences(
user_id=user_id,
notify_proximity=bool(row["notify_proximity"]),
notify_breakdown=bool(row["notify_breakdown"]),
notify_delay=bool(row["notify_delay"]),
notify_route_start=bool(row["notify_route_start"]),
)
def obtener_usuarios_por_ruta(self, route_id: str) -> list[dict]:
conn = get_connection()
rows = conn.execute(
"SELECT id as address_id, user_id FROM addresses WHERE route_id = ?",
(route_id,),
).fetchall()
conn.close()
return [dict(r) for r in rows]
# ── Templates de notificación ─────────────────────────────────────
def obtener_template(self, trigger_event: str) -> Optional[dict]:
conn = get_connection()
row = conn.execute(
"SELECT * FROM notification_templates WHERE trigger_event = ?",
(trigger_event,),
).fetchone()
conn.close()
return dict(row) if row else None
# ── ETA calculado con soporte de caché ────────────────────────────
def calcular_eta(self, address_id: int) -> Optional[ETAResult]:
ruta = self.obtener_ruta_por_address(address_id)
if not ruta:
return None
ts = self.obtener_truck_status(ruta.id)
if not ts:
return ETAResult(
address_id=address_id, route_id=ruta.id,
status="SIN_INICIAR", eta_minutos=None,
ventana_inicio=None, ventana_fin=None,
mensaje="El camión aún no ha iniciado su ruta.",
)
pos = ts.current_position_id
puntos = {p.orden: p for p in ruta.puntos}
ultimo = ruta.puntos[-1]
actual = puntos.get(pos, ruta.puntos[0])
eta_min = max(0, ultimo.tiempo_estimado_min - actual.tiempo_estimado_min)
from datetime import timedelta
ahora = datetime.now()
llegada = ahora + timedelta(minutes=eta_min)
v_ini = (llegada - timedelta(minutes=7)).strftime("%I:%M %p").lstrip("0")
v_fin = (llegada + timedelta(minutes=7)).strftime("%I:%M %p").lstrip("0")
if ts.status == EstadoCamion.APROXIMANDOSE:
msg = f"El camión llegará a tu zona entre las {v_ini} y {v_fin}."
elif ts.status in (EstadoCamion.AVERIADA, EstadoCamion.RETRASADA):
msg = "El camión reportó una incidencia. Te notificaremos cuando se reanude."
v_ini = v_fin = None
else:
msg = f"El camión está en camino. Llegada estimada: {v_ini} {v_fin}."
return ETAResult(
address_id=address_id,
route_id=ruta.id,
status=ts.status.value,
eta_minutos=eta_min,
ventana_inicio=v_ini,
ventana_fin=v_fin,
mensaje=msg,
)
def guardar_notificacion(self, tipo: str, route_id: str,
address_id: int, mensaje: str,
eta_minutos: Optional[int]) -> None:
conn = get_connection()
conn.execute("""
INSERT INTO notificaciones
(tipo, ruta_id, address_id, mensaje, eta_minutos, creada_en)
VALUES (?, ?, ?, ?, ?, ?)
""", (tipo, route_id, address_id, mensaje,
eta_minutos, datetime.utcnow().isoformat()))
conn.commit()
conn.close()

View File

@@ -0,0 +1,3 @@
from app.db.database import get_connection, init_db
__all__ = ["get_connection", "init_db"]

142
server/app/db/database.py Normal file
View File

@@ -0,0 +1,142 @@
"""
Base de datos SQLite — esquema unificado con Persona A.
Tablas propias del módulo B: truck_status, notificaciones, ws_sessions.
"""
import sqlite3
from pathlib import Path
DB_PATH = Path("basura.db")
def get_connection() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db() -> None:
conn = get_connection()
conn.executescript("""
-- ── Tablas de Persona A (las creamos aquí para que el módulo B
-- pueda leerlas aunque A no haya corrido aún) ──────────────
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
phone TEXT,
password_hash TEXT NOT NULL,
fcm_token TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
alias TEXT,
lat REAL NOT NULL,
lng REAL NOT NULL,
route_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS notification_preferences (
user_id INTEGER PRIMARY KEY,
notify_proximity BOOLEAN DEFAULT 1,
notify_breakdown BOOLEAN DEFAULT 1,
notify_delay BOOLEAN DEFAULT 1,
notify_route_start BOOLEAN DEFAULT 1,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS notification_templates (
id INTEGER PRIMARY KEY,
trigger_event TEXT UNIQUE,
title TEXT,
body TEXT
);
-- ── Tablas del módulo B ───────────────────────────────────────
CREATE TABLE IF NOT EXISTS truck_status (
route_id TEXT PRIMARY KEY,
current_position_id INTEGER DEFAULT 1,
last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'EN_RUTA'
);
CREATE TABLE IF NOT EXISTS rutas (
id TEXT PRIMARY KEY,
nombre TEXT NOT NULL,
turno TEXT NOT NULL DEFAULT 'mañana'
);
CREATE TABLE IF NOT EXISTS puntos_ruta (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ruta_id TEXT NOT NULL REFERENCES rutas(id),
orden INTEGER NOT NULL,
nombre TEXT NOT NULL,
lat REAL NOT NULL,
lng REAL NOT NULL,
tiempo_estimado_min INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS notificaciones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tipo TEXT NOT NULL,
ruta_id TEXT NOT NULL,
address_id INTEGER,
mensaje TEXT NOT NULL,
eta_minutos INTEGER,
creada_en TEXT NOT NULL
);
""")
conn.commit()
conn.close()
_seed_datos_demo()
def _seed_datos_demo() -> None:
conn = get_connection()
existe = conn.execute("SELECT 1 FROM rutas WHERE id='RUTA-01'").fetchone()
if existe:
conn.close()
return
conn.executescript("""
-- Ruta de demo (Celaya, Guanajuato)
INSERT INTO rutas VALUES ('RUTA-01', 'Ruta 01 — Sector Centro', 'mañana');
INSERT INTO puntos_ruta (ruta_id, orden, nombre, lat, lng, tiempo_estimado_min)
VALUES
('RUTA-01', 1, 'Estación Central', 20.5238, -100.8143, 0),
('RUTA-01', 2, 'Col. Independencia', 20.5255, -100.8090, 8),
('RUTA-01', 3, 'Blvd. A. López Mateos', 20.5271, -100.8021, 18),
('RUTA-01', 4, 'Col. Jardines del Bosque', 20.5290, -100.7965, 28),
('RUTA-01', 5, 'Mercado Hidalgo', 20.5310, -100.7910, 38);
INSERT INTO truck_status VALUES ('RUTA-01', 1, CURRENT_TIMESTAMP, 'EN_RUTA');
-- Usuario de demo
INSERT INTO users (email, phone, password_hash)
VALUES ('demo@basura.app', '4611234567', 'hashed_demo');
-- Domicilio de demo asignado a RUTA-01
INSERT INTO addresses (user_id, alias, lat, lng, route_id)
VALUES (1, 'Casa', 20.5285, -100.7980, 'RUTA-01');
-- Preferencias por defecto para usuario demo
INSERT INTO notification_preferences VALUES (1, 1, 1, 1, 1);
-- Templates de notificación
INSERT INTO notification_templates (trigger_event, title, body) VALUES
('ruta_iniciada', 'Ruta iniciada', 'El camión ha comenzado su ruta. Prepárate.'),
('aproximandose', '¡Camión cerca!', 'El camión llega en ~{eta} minutos. Saca tu basura.'),
('falla_mecanica', 'Aviso de servicio', 'El camión reportó una falla. Te notificaremos cuando se reanude.'),
('ruta_tarde', 'Cambio de horario', 'El camión de la mañana pasará en el turno de la tarde.'),
('completado', 'Ruta completada', 'El camión completó su paso por tu zona. ¡Hasta mañana!');
""")
conn.commit()
conn.close()

View File

@@ -0,0 +1,21 @@
from app.domain.entities.ruta import (
Coordenada,
EstadoCamion,
ETAResult,
NotificationPreferences,
PuntoRuta,
Ruta,
TruckStatus,
TipoNotificacion,
)
__all__ = [
"Coordenada",
"EstadoCamion",
"ETAResult",
"NotificationPreferences",
"PuntoRuta",
"Ruta",
"TruckStatus",
"TipoNotificacion",
]

View File

@@ -0,0 +1,21 @@
from app.domain.entities.ruta import (
Coordenada,
EstadoCamion,
ETAResult,
NotificationPreferences,
PuntoRuta,
Ruta,
TruckStatus,
TipoNotificacion,
)
__all__ = [
"Coordenada",
"EstadoCamion",
"ETAResult",
"NotificationPreferences",
"PuntoRuta",
"Ruta",
"TruckStatus",
"TipoNotificacion",
]

View File

@@ -0,0 +1,77 @@
"""
Entidades del dominio — sin dependencias externas.
Alineadas con el esquema de Persona A.
"""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Optional
class EstadoCamion(str, Enum):
EN_RUTA = "EN_RUTA"
APROXIMANDOSE = "APROXIMANDOSE"
COMPLETADO = "COMPLETADO"
AVERIADA = "AVERIADA" # truck_status: AVERIADA
RETRASADA = "RETRASADA" # truck_status: RETRASADA
class TipoNotificacion(str, Enum):
RUTA_INICIADA = "ruta_iniciada"
APROXIMANDOSE = "aproximandose"
COMPLETADO = "completado"
FALLA_MECANICA = "falla_mecanica"
RUTA_TARDE = "ruta_tarde"
@dataclass
class Coordenada:
lat: float
lng: float
@dataclass
class PuntoRuta:
orden: int # == current_position_id en truck_status
nombre: str
coordenada: Coordenada
tiempo_estimado_min: int
@dataclass
class Ruta:
id: str # ej. "RUTA-01"
nombre: str
puntos: list[PuntoRuta] = field(default_factory=list)
turno: str = "mañana"
@dataclass
class TruckStatus:
"""Espejo directo de la tabla truck_status de Persona A."""
route_id: str
current_position_id: int
last_update: datetime
status: EstadoCamion
@dataclass
class NotificationPreferences:
"""Preferencias del usuario — leídas antes de cada notificación."""
user_id: int
notify_proximity: bool = True
notify_breakdown: bool = True
notify_delay: bool = True
notify_route_start: bool = True
@dataclass
class ETAResult:
"""Lo que ve el ciudadano — sin coordenadas, sin índice de waypoint."""
address_id: int
route_id: str
status: str
eta_minutos: Optional[int]
ventana_inicio: Optional[str] # ej. "7:20 pm"
ventana_fin: Optional[str] # ej. "7:35 pm"
mensaje: str

View File

@@ -0,0 +1,3 @@
from app.domain.interfaces.i_ruta_repository import IRutaRepository
__all__ = ["IRutaRepository"]

View File

@@ -0,0 +1,32 @@
"""
Interfaces del dominio.
El dominio define QUÉ necesita, no CÓMO se implementa.
La capa de Datos implementa estas interfaces.
"""
from abc import ABC, abstractmethod
from typing import Optional
from app.domain.entities.ruta import EstadoRuta, Ruta
class IRutaRepository(ABC):
@abstractmethod
def obtener_ruta(self, ruta_id: str) -> Optional[Ruta]:
...
@abstractmethod
def obtener_estado(self, ruta_id: str) -> Optional[EstadoRuta]:
...
@abstractmethod
def guardar_estado(self, estado: EstadoRuta) -> None:
...
@abstractmethod
def obtener_ruta_por_zona(self, zona_id: str) -> Optional[Ruta]:
"""
Devuelve la ruta asignada a una zona.
Cumple la restricción de 'túnel': el domicilio solo ve su ruta.
"""
...

48
server/app/main.py Normal file
View File

@@ -0,0 +1,48 @@
"""
Punto de entrada de la aplicación.
Ejecutar con: uvicorn app.main:app --reload
"""
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes.eta_router import router as eta_router
from app.api.routes.auth_router import router as auth_router
from app.api.routes.addresses_router import router as addresses_router
from app.api.routes.guide_router import router as guide_router # ← IMPORTANTE: agregar esta línea
from app.core.config import settings
from app.db.database import init_db
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
app = FastAPI(
title=settings.app_name,
version="0.1.0",
description="API de notificación inteligente de recolección de residuos",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup():
init_db()
logging.info("Base de datos inicializada ✓")
# Include routers
app.include_router(auth_router, prefix="/auth", tags=["Authentication"])
app.include_router(addresses_router, prefix="/addresses", tags=["Addresses"])
app.include_router(eta_router, tags=["ETA / Simulador"])
app.include_router(guide_router, tags=["Recycling Guide"])
@app.get("/health", tags=["Health"])
async def health():
return {"status": "ok", "app": settings.app_name}

View File

@@ -0,0 +1,4 @@
from app.services.simulador import SimuladorRuta, obtener_simulador
from app.services.ws_manager import ws_manager
__all__ = ["SimuladorRuta", "obtener_simulador", "ws_manager"]

View File

@@ -0,0 +1,214 @@
"""
Simulador de Ruta — Módulo B
Avanza `truck_status.current_position_id` cada tick.
Antes de cada push verifica `notification_preferences` del usuario.
Nunca envía coordenadas al cliente — solo ETA + mensaje.
"""
import asyncio
import logging
from datetime import datetime
from typing import Optional
from app.core.config import settings
from app.data.repositories.ruta_repository import SQLiteRutaRepository
from app.domain.entities.ruta import EstadoCamion, TruckStatus, TipoNotificacion
from app.services.ws_manager import ws_manager
logger = logging.getLogger(__name__)
class SimuladorRuta:
def __init__(self, route_id: str, tick_segundos: int = None):
self.route_id = route_id
self.tick = tick_segundos or settings.sim_tick_seconds
self.repo = SQLiteRutaRepository()
self._tarea: Optional[asyncio.Task] = None
self._corriendo = False
# ── Control ────────────────────────────────────────────────────────
def iniciar(self) -> None:
if self._corriendo:
return
self._corriendo = True
self._tarea = asyncio.create_task(self._loop())
logger.info(f"[SIM] {self.route_id} iniciada")
def detener(self) -> None:
self._corriendo = False
if self._tarea:
self._tarea.cancel()
async def forzar_averia(self, mensaje: str = "Falla mecánica reportada.") -> None:
"""Endpoint /alerts/breakdown llama esto."""
ts = self.repo.obtener_truck_status(self.route_id)
if not ts:
return
ts.status = EstadoCamion.AVERIADA
ts.last_update = datetime.utcnow()
self.repo.guardar_truck_status(ts)
self.detener()
await self._broadcast_a_usuarios(
tipo=TipoNotificacion.FALLA_MECANICA,
eta_minutos=None,
mensaje=mensaje,
preferencia_key="notify_breakdown",
)
logger.warning(f"[SIM] Avería registrada en {self.route_id}")
async def forzar_retraso(self, mensaje: str = "El camión reportó un retraso.") -> None:
ts = self.repo.obtener_truck_status(self.route_id)
if not ts:
return
ts.status = EstadoCamion.RETRASADA
ts.last_update = datetime.utcnow()
self.repo.guardar_truck_status(ts)
await self._broadcast_a_usuarios(
tipo=TipoNotificacion.RUTA_TARDE,
eta_minutos=None,
mensaje=mensaje,
preferencia_key="notify_delay",
)
# ── Loop principal ─────────────────────────────────────────────────
async def _loop(self) -> None:
ruta = self.repo.obtener_ruta(self.route_id)
if not ruta or not ruta.puntos:
logger.error(f"[SIM] Ruta {self.route_id} sin puntos")
return
# Inicializar truck_status en posición 1
ts = TruckStatus(
route_id=self.route_id,
current_position_id=1,
last_update=datetime.utcnow(),
status=EstadoCamion.EN_RUTA,
)
self.repo.guardar_truck_status(ts)
await self._broadcast_a_usuarios(
tipo=TipoNotificacion.RUTA_INICIADA,
eta_minutos=ruta.puntos[-1].tiempo_estimado_min,
mensaje="El camión ha iniciado su ruta. Prepárate.",
preferencia_key="notify_route_start",
)
umbral = settings.sim_eta_alert_minutes
ultimo_punto = ruta.puntos[-1]
for punto in ruta.puntos[1:]:
if not self._corriendo:
break
await asyncio.sleep(self.tick)
eta = max(0, ultimo_punto.tiempo_estimado_min - punto.tiempo_estimado_min)
# Detectar umbral de proximidad
if eta <= umbral and ts.status == EstadoCamion.EN_RUTA:
ts.status = EstadoCamion.APROXIMANDOSE
tipo = TipoNotificacion.APROXIMANDOSE
pref_key = "notify_proximity"
msg = (
f"El camión llega en ~{eta} minutos. "
"Saca tu basura ahora."
)
else:
tipo = TipoNotificacion.RUTA_INICIADA
pref_key = "notify_route_start"
msg = f"El camión está en camino. Llegada estimada en ~{eta} min."
ts.current_position_id = punto.orden
ts.last_update = datetime.utcnow()
self.repo.guardar_truck_status(ts)
await self._broadcast_a_usuarios(
tipo=tipo,
eta_minutos=eta,
mensaje=msg,
preferencia_key=pref_key,
)
logger.info(f"[SIM] Pos {punto.orden} | ETA {eta} min | {ts.status}")
if self._corriendo:
ts.status = EstadoCamion.COMPLETADO
ts.last_update = datetime.utcnow()
self.repo.guardar_truck_status(ts)
await self._broadcast_a_usuarios(
tipo=TipoNotificacion.COMPLETADO,
eta_minutos=0,
mensaje="El camión completó su paso. ¡Hasta mañana!",
preferencia_key=None, # completado siempre se notifica
)
self._corriendo = False
logger.info(f"[SIM] {self.route_id} completada")
# ── Broadcast respetando preferencias ─────────────────────────────
async def _broadcast_a_usuarios(
self,
tipo: TipoNotificacion,
eta_minutos: Optional[int],
mensaje: str,
preferencia_key: Optional[str],
) -> None:
"""
Por cada domicilio en la ruta:
1. Consulta las preferencias del usuario.
2. Solo envía si la preferencia está activa.
3. Persiste la notificación en BD.
4. Empuja por WebSocket al address_id correspondiente.
"""
template = self.repo.obtener_template(tipo.value)
if template and "{eta}" in template["body"]:
mensaje = template["body"].replace("{eta}", str(eta_minutos or "?"))
usuarios = self.repo.obtener_usuarios_por_ruta(self.route_id)
for u in usuarios:
user_id = u["user_id"]
address_id = u["address_id"]
# Verificar preferencia
if preferencia_key:
prefs = self.repo.obtener_preferencias(user_id)
if not getattr(prefs, preferencia_key, True):
logger.debug(
f"[SIM] Usuario {user_id} desactivó {preferencia_key}, skip"
)
continue
payload = {
"tipo": tipo.value,
"address_id": address_id,
"eta_minutos": eta_minutos,
"mensaje": mensaje,
"hora_utc": datetime.utcnow().isoformat(),
}
# Push WebSocket — el cliente escucha en /ws/{address_id}
await ws_manager.broadcast_zona(str(address_id), payload)
# Persistir
self.repo.guardar_notificacion(
tipo=tipo.value,
route_id=self.route_id,
address_id=address_id,
mensaje=mensaje,
eta_minutos=eta_minutos,
)
# ── Registro global ────────────────────────────────────────────────────
_simuladores: dict[str, SimuladorRuta] = {}
def obtener_simulador(route_id: str) -> SimuladorRuta:
if route_id not in _simuladores:
_simuladores[route_id] = SimuladorRuta(route_id)
return _simuladores[route_id]

View File

@@ -0,0 +1,55 @@
"""
Gestor de conexiones WebSocket.
Mantiene un registro de qué clientes están conectados y a qué zona pertenecen.
El simulador llama a broadcast_zona() para empujar eventos sin polling.
"""
import json
from collections import defaultdict
from fastapi import WebSocket
class WebSocketManager:
def __init__(self):
# zona_id -> lista de WebSockets activos
self._conexiones: dict[str, list[WebSocket]] = defaultdict(list)
async def conectar(self, websocket: WebSocket, zona_id: str) -> None:
await websocket.accept()
self._conexiones[zona_id].append(websocket)
def desconectar(self, websocket: WebSocket, zona_id: str) -> None:
conexiones = self._conexiones.get(zona_id, [])
if websocket in conexiones:
conexiones.remove(websocket)
async def broadcast_zona(self, zona_id: str, payload: dict) -> None:
"""Envía un mensaje a todos los clientes de una zona."""
mensaje = json.dumps(payload, ensure_ascii=False)
muertos: list[WebSocket] = []
for ws in self._conexiones.get(zona_id, []):
try:
await ws.send_text(mensaje)
except Exception:
muertos.append(ws)
for ws in muertos:
self.desconectar(ws, zona_id)
async def broadcast_ruta(self, ruta_id: str, payload: dict) -> None:
"""
Envía a TODAS las zonas de una ruta.
El filtro real de privacidad está en el backend (RBAC del endpoint REST).
Aquí simplemente distribuimos por zona registrada.
"""
for zona_id, conexiones in self._conexiones.items():
if conexiones:
await self.broadcast_zona(zona_id, payload)
def zonas_activas(self) -> list[str]:
return [z for z, ws in self._conexiones.items() if ws]
# Singleton global compartido por el simulador y el router de WebSocket
ws_manager = WebSocketManager()

View File

@@ -0,0 +1,3 @@
from app.use_cases.obtener_eta import ObtenerETAUseCase, ETAResponse
__all__ = ["ObtenerETAUseCase", "ETAResponse"]

View File

@@ -0,0 +1,46 @@
"""
Caso de Uso: ObtenerETA
Orquesta la lógica: valida que la zona pertenece al usuario,
busca la ruta asignada y devuelve solo el ETA.
"""
from dataclasses import dataclass
from typing import Optional
from app.domain.interfaces.i_ruta_repository import IRutaRepository
@dataclass
class ETAResponse:
zona_id: str
estado: str
eta_minutos: Optional[int]
mensaje: str
ruta_nombre: str
class ObtenerETAUseCase:
def __init__(self, repo: IRutaRepository):
self.repo = repo
def ejecutar(self, zona_id: str) -> Optional[ETAResponse]:
ruta = self.repo.obtener_ruta_por_zona(zona_id)
if not ruta:
return None
estado = self.repo.obtener_estado(ruta.id)
if not estado:
return ETAResponse(
zona_id=zona_id,
estado="sin_iniciar",
eta_minutos=None,
mensaje="El camión aún no ha iniciado su ruta para hoy.",
ruta_nombre=ruta.nombre,
)
return ETAResponse(
zona_id=zona_id,
estado=estado.estado.value,
eta_minutos=estado.eta_minutos,
mensaje=estado.mensaje,
ruta_nombre=ruta.nombre,
)

60
server/docker-compose.yml Normal file
View File

@@ -0,0 +1,60 @@
version: '3.8'
services:
redis:
image: redis:7-alpine
container_name: basura-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
restart: unless-stopped
networks:
- basura-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
api:
build: .
container_name: basura-backend
ports:
- "0.0.0.0:8000:8000"
environment:
- SECRET_KEY=${SECRET_KEY:-dev-secret-key-change-in-production}
- DATABASE_PATH=/data/basura.db
- DEBUG=${DEBUG:-true}
- SIM_TICK_SECONDS=${SIM_TICK_SECONDS:-10}
- SIM_ETA_ALERT_MINUTES=${SIM_ETA_ALERT_MINUTES:-10}
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_DB=0
- CACHE_ENABLED=${CACHE_ENABLED:-true}
- CACHE_TTL_ETA=${CACHE_TTL_ETA:-30}
- CACHE_TTL_ADDRESSES=${CACHE_TTL_ADDRESSES:-300}
- CACHE_TTL_GUIDE=${CACHE_TTL_GUIDE:-86400}
volumes:
- ./data:/data
- ./logs:/app/logs
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- basura-network
networks:
basura-network:
driver: bridge
volumes:
redis-data:

27
server/dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ ./app/
COPY tests/ ./tests/
# Create data directory for SQLite
RUN mkdir -p /data
# Environment variables
ENV PYTHONPATH=/app
ENV DATABASE_PATH=/data/basura.db
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

15
server/requirements.txt Normal file
View File

@@ -0,0 +1,15 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
websockets==12.0
apscheduler==3.10.4
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.0
pydantic==2.5.0
pydantic-settings==2.1.0
httpx==0.25.0
pyjwt==2.8.0
bcrypt==4.1.2
email-validator==2.1.0
redis==5.0.1
hiredis==2.3.2

127
server/schema_supabase.sql Normal file
View File

@@ -0,0 +1,127 @@
-- ── Esquema Supabase PostgreSQL (migrado de SQLite) ──────────────
-- Tabla users (Persona A)
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
phone TEXT,
password_hash TEXT NOT NULL,
fcm_token TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Tabla addresses (Persona A)
CREATE TABLE IF NOT EXISTS addresses (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
alias TEXT,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
route_id TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Tabla notification_preferences (Persona A)
CREATE TABLE IF NOT EXISTS notification_preferences (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
notify_proximity BOOLEAN DEFAULT TRUE,
notify_breakdown BOOLEAN DEFAULT TRUE,
notify_delay BOOLEAN DEFAULT TRUE,
notify_route_start BOOLEAN DEFAULT TRUE
);
-- Tabla notification_templates (Persona A)
CREATE TABLE IF NOT EXISTS notification_templates (
id BIGSERIAL PRIMARY KEY,
trigger_event TEXT UNIQUE,
title TEXT,
body TEXT
);
-- ── Tablas del módulo B ───────────────────────────────────────
-- Tabla truck_status
CREATE TABLE IF NOT EXISTS truck_status (
route_id TEXT PRIMARY KEY,
current_position_id INTEGER DEFAULT 1,
last_update TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'EN_RUTA'
);
-- Tabla rutas
CREATE TABLE IF NOT EXISTS rutas (
id TEXT PRIMARY KEY,
nombre TEXT NOT NULL,
turno TEXT NOT NULL DEFAULT 'mañana'
);
-- Tabla puntos_ruta
CREATE TABLE IF NOT EXISTS puntos_ruta (
id BIGSERIAL PRIMARY KEY,
ruta_id TEXT NOT NULL REFERENCES rutas(id),
orden INTEGER NOT NULL,
nombre TEXT NOT NULL,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
tiempo_estimado_min INTEGER NOT NULL
);
-- Tabla notificaciones
CREATE TABLE IF NOT EXISTS notificaciones (
id BIGSERIAL PRIMARY KEY,
tipo TEXT NOT NULL,
ruta_id TEXT NOT NULL,
address_id BIGINT,
mensaje TEXT NOT NULL,
eta_minutos INTEGER,
creada_en TEXT NOT NULL
);
-- ── Índices para performance ──────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_addresses_user_id ON addresses(user_id);
CREATE INDEX IF NOT EXISTS idx_addresses_route_id ON addresses(route_id);
CREATE INDEX IF NOT EXISTS idx_puntos_ruta_ruta_id ON puntos_ruta(ruta_id);
CREATE INDEX IF NOT EXISTS idx_notificaciones_ruta_id ON notificaciones(ruta_id);
-- ── RLS (Row Level Security) ──────────────────────────────────
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE addresses ENABLE ROW LEVEL SECURITY;
ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE notificaciones ENABLE ROW LEVEL SECURITY;
-- Policy: users solo ven su propio perfil
CREATE POLICY "Users can view own profile" ON users
FOR SELECT USING (auth.uid()::TEXT = id::TEXT);
-- Policy: addresses solo de usuario autenticado
CREATE POLICY "Users can view own addresses" ON addresses
FOR SELECT USING (user_id = auth.uid()::BIGINT);
-- Policy: notification_preferences solo de usuario autenticado
CREATE POLICY "Users can view own preferences" ON notification_preferences
FOR SELECT USING (user_id = auth.uid()::BIGINT);
-- ── Seed data ──────────────────────────────────────────────────
INSERT INTO rutas (id, nombre, turno) VALUES
('RUTA-01', 'Ruta 01 — Sector Centro', 'mañana')
ON CONFLICT (id) DO NOTHING;
INSERT INTO puntos_ruta (ruta_id, orden, nombre, lat, lng, tiempo_estimado_min) VALUES
('RUTA-01', 1, 'Estación Central', 20.5238, -100.8143, 0),
('RUTA-01', 2, 'Col. Independencia', 20.5255, -100.8090, 8),
('RUTA-01', 3, 'Blvd. A. López Mateos', 20.5271, -100.8021, 18),
('RUTA-01', 4, 'Col. Jardines del Bosque', 20.5290, -100.7965, 28),
('RUTA-01', 5, 'Mercado Hidalgo', 20.5310, -100.7910, 38)
ON CONFLICT DO NOTHING;
INSERT INTO truck_status (route_id, current_position_id, status) VALUES
('RUTA-01', 1, 'EN_RUTA')
ON CONFLICT (route_id) DO NOTHING;
INSERT INTO notification_templates (trigger_event, title, body) VALUES
('ruta_iniciada', 'Ruta iniciada', 'El camión ha comenzado su ruta. Prepárate.'),
('aproximandose', '¡Camión cerca!', 'El camión llega en ~{eta} minutos. Saca tu basura.'),
('falla_mecanica', 'Aviso de servicio', 'El camión reportó una falla. Te notificaremos cuando se reanude.'),
('ruta_tarde', 'Cambio de horario', 'El camión de la mañana pasará en el turno de la tarde.'),
('completado', 'Ruta completada', 'El camión completó su paso por tu zona. ¡Hasta mañana!')
ON CONFLICT (trigger_event) DO NOTHING;

0
server/tests/__init__.py Normal file
View File