From e6eb466c14758590b4ad32da2e28e605836d2615 Mon Sep 17 00:00:00 2001 From: Alan Alonso Date: Sat, 23 May 2026 00:41:13 -0600 Subject: [PATCH] feat: add backend FastAPI structure and Supabase schema --- lib/core/config/supabase_config.dart | 0 server/.dockerignore | 21 ++ server/.env | 7 + server/.env.example | 5 + server/__init__.py | 0 server/app/__init__.py | 0 server/app/api/__init__.py | 3 + server/app/api/routes/__init__.py | 3 + server/app/api/routes/addresses_router.py | 43 ++++ server/app/api/routes/auth_router.py | 77 +++++++ server/app/api/routes/eta_router.py | 154 +++++++++++++ server/app/api/routes/guide_router.py | 82 +++++++ server/app/core/__init__.py | 3 + server/app/core/cache.py | 198 ++++++++++++++++ server/app/core/config.py | 35 +++ server/app/core/dependencies.py | 36 +++ server/app/data/__init__.py | 3 + server/app/data/models/__init__.py | 0 server/app/data/repositories/__init__.py | 3 + .../app/data/repositories/ruta_repository.py | 189 ++++++++++++++++ server/app/db/__init__.py | 3 + server/app/db/database.py | 142 ++++++++++++ server/app/domain/__init__.py | 21 ++ server/app/domain/entities/__init__.py | 21 ++ server/app/domain/entities/ruta.py | 77 +++++++ server/app/domain/interfaces/__init__.py | 3 + .../domain/interfaces/i_ruta_repository.py | 32 +++ server/app/main.py | 48 ++++ server/app/services/__init__.py | 4 + server/app/services/simulador.py | 214 ++++++++++++++++++ server/app/services/ws_manager.py | 55 +++++ server/app/use_cases/__init__.py | 3 + server/app/use_cases/obtener_eta.py | 46 ++++ server/docker-compose.yml | 60 +++++ server/dockerfile | 27 +++ server/requirements.txt | 15 ++ server/schema_supabase.sql | 127 +++++++++++ server/tests/__init__.py | 0 38 files changed, 1760 insertions(+) create mode 100644 lib/core/config/supabase_config.dart create mode 100644 server/.dockerignore create mode 100644 server/.env create mode 100644 server/.env.example create mode 100644 server/__init__.py create mode 100644 server/app/__init__.py create mode 100644 server/app/api/__init__.py create mode 100644 server/app/api/routes/__init__.py create mode 100644 server/app/api/routes/addresses_router.py create mode 100644 server/app/api/routes/auth_router.py create mode 100644 server/app/api/routes/eta_router.py create mode 100644 server/app/api/routes/guide_router.py create mode 100644 server/app/core/__init__.py create mode 100644 server/app/core/cache.py create mode 100644 server/app/core/config.py create mode 100644 server/app/core/dependencies.py create mode 100644 server/app/data/__init__.py create mode 100644 server/app/data/models/__init__.py create mode 100644 server/app/data/repositories/__init__.py create mode 100644 server/app/data/repositories/ruta_repository.py create mode 100644 server/app/db/__init__.py create mode 100644 server/app/db/database.py create mode 100644 server/app/domain/__init__.py create mode 100644 server/app/domain/entities/__init__.py create mode 100644 server/app/domain/entities/ruta.py create mode 100644 server/app/domain/interfaces/__init__.py create mode 100644 server/app/domain/interfaces/i_ruta_repository.py create mode 100644 server/app/main.py create mode 100644 server/app/services/__init__.py create mode 100644 server/app/services/simulador.py create mode 100644 server/app/services/ws_manager.py create mode 100644 server/app/use_cases/__init__.py create mode 100644 server/app/use_cases/obtener_eta.py create mode 100644 server/docker-compose.yml create mode 100644 server/dockerfile create mode 100644 server/requirements.txt create mode 100644 server/schema_supabase.sql create mode 100644 server/tests/__init__.py diff --git a/lib/core/config/supabase_config.dart b/lib/core/config/supabase_config.dart new file mode 100644 index 0000000..e69de29 diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..2061a47 --- /dev/null +++ b/server/.dockerignore @@ -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 \ No newline at end of file diff --git a/server/.env b/server/.env new file mode 100644 index 0000000..7c95bf9 --- /dev/null +++ b/server/.env @@ -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 \ No newline at end of file diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..51a010f --- /dev/null +++ b/server/.env.example @@ -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 \ No newline at end of file diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/api/__init__.py b/server/app/api/__init__.py new file mode 100644 index 0000000..d5ebca0 --- /dev/null +++ b/server/app/api/__init__.py @@ -0,0 +1,3 @@ +from app.api.routes.eta_router import router + +__all__ = ["router"] \ No newline at end of file diff --git a/server/app/api/routes/__init__.py b/server/app/api/routes/__init__.py new file mode 100644 index 0000000..d5ebca0 --- /dev/null +++ b/server/app/api/routes/__init__.py @@ -0,0 +1,3 @@ +from app.api.routes.eta_router import router + +__all__ = ["router"] \ No newline at end of file diff --git a/server/app/api/routes/addresses_router.py b/server/app/api/routes/addresses_router.py new file mode 100644 index 0000000..8de06a5 --- /dev/null +++ b/server/app/api/routes/addresses_router.py @@ -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] \ No newline at end of file diff --git a/server/app/api/routes/auth_router.py b/server/app/api/routes/auth_router.py new file mode 100644 index 0000000..5147acd --- /dev/null +++ b/server/app/api/routes/auth_router.py @@ -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) \ No newline at end of file diff --git a/server/app/api/routes/eta_router.py b/server/app/api/routes/eta_router.py new file mode 100644 index 0000000..f0d4fb3 --- /dev/null +++ b/server/app/api/routes/eta_router.py @@ -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), + } \ No newline at end of file diff --git a/server/app/api/routes/guide_router.py b/server/app/api/routes/guide_router.py new file mode 100644 index 0000000..d9aee1b --- /dev/null +++ b/server/app/api/routes/guide_router.py @@ -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 \ No newline at end of file diff --git a/server/app/core/__init__.py b/server/app/core/__init__.py new file mode 100644 index 0000000..83ce6ae --- /dev/null +++ b/server/app/core/__init__.py @@ -0,0 +1,3 @@ +from app.core.config import settings + +__all__ = ["settings"] \ No newline at end of file diff --git a/server/app/core/cache.py b/server/app/core/cache.py new file mode 100644 index 0000000..44527d7 --- /dev/null +++ b/server/app/core/cache.py @@ -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}") \ No newline at end of file diff --git a/server/app/core/config.py b/server/app/core/config.py new file mode 100644 index 0000000..1425ec0 --- /dev/null +++ b/server/app/core/config.py @@ -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() \ No newline at end of file diff --git a/server/app/core/dependencies.py b/server/app/core/dependencies.py new file mode 100644 index 0000000..b3bf09d --- /dev/null +++ b/server/app/core/dependencies.py @@ -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") \ No newline at end of file diff --git a/server/app/data/__init__.py b/server/app/data/__init__.py new file mode 100644 index 0000000..9d95628 --- /dev/null +++ b/server/app/data/__init__.py @@ -0,0 +1,3 @@ +from app.data.repositories.ruta_repository import SQLiteRutaRepository + +__all__ = ["SQLiteRutaRepository"] \ No newline at end of file diff --git a/server/app/data/models/__init__.py b/server/app/data/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/data/repositories/__init__.py b/server/app/data/repositories/__init__.py new file mode 100644 index 0000000..9d95628 --- /dev/null +++ b/server/app/data/repositories/__init__.py @@ -0,0 +1,3 @@ +from app.data.repositories.ruta_repository import SQLiteRutaRepository + +__all__ = ["SQLiteRutaRepository"] \ No newline at end of file diff --git a/server/app/data/repositories/ruta_repository.py b/server/app/data/repositories/ruta_repository.py new file mode 100644 index 0000000..92b68e6 --- /dev/null +++ b/server/app/data/repositories/ruta_repository.py @@ -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() \ No newline at end of file diff --git a/server/app/db/__init__.py b/server/app/db/__init__.py new file mode 100644 index 0000000..14dd828 --- /dev/null +++ b/server/app/db/__init__.py @@ -0,0 +1,3 @@ +from app.db.database import get_connection, init_db + +__all__ = ["get_connection", "init_db"] \ No newline at end of file diff --git a/server/app/db/database.py b/server/app/db/database.py new file mode 100644 index 0000000..616a280 --- /dev/null +++ b/server/app/db/database.py @@ -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() diff --git a/server/app/domain/__init__.py b/server/app/domain/__init__.py new file mode 100644 index 0000000..88d57bb --- /dev/null +++ b/server/app/domain/__init__.py @@ -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", +] \ No newline at end of file diff --git a/server/app/domain/entities/__init__.py b/server/app/domain/entities/__init__.py new file mode 100644 index 0000000..88d57bb --- /dev/null +++ b/server/app/domain/entities/__init__.py @@ -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", +] \ No newline at end of file diff --git a/server/app/domain/entities/ruta.py b/server/app/domain/entities/ruta.py new file mode 100644 index 0000000..fd27be2 --- /dev/null +++ b/server/app/domain/entities/ruta.py @@ -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 diff --git a/server/app/domain/interfaces/__init__.py b/server/app/domain/interfaces/__init__.py new file mode 100644 index 0000000..4a9ac4c --- /dev/null +++ b/server/app/domain/interfaces/__init__.py @@ -0,0 +1,3 @@ +from app.domain.interfaces.i_ruta_repository import IRutaRepository + +__all__ = ["IRutaRepository"] \ No newline at end of file diff --git a/server/app/domain/interfaces/i_ruta_repository.py b/server/app/domain/interfaces/i_ruta_repository.py new file mode 100644 index 0000000..e763161 --- /dev/null +++ b/server/app/domain/interfaces/i_ruta_repository.py @@ -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. + """ + ... diff --git a/server/app/main.py b/server/app/main.py new file mode 100644 index 0000000..5525638 --- /dev/null +++ b/server/app/main.py @@ -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} \ No newline at end of file diff --git a/server/app/services/__init__.py b/server/app/services/__init__.py new file mode 100644 index 0000000..8eae203 --- /dev/null +++ b/server/app/services/__init__.py @@ -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"] \ No newline at end of file diff --git a/server/app/services/simulador.py b/server/app/services/simulador.py new file mode 100644 index 0000000..61fe898 --- /dev/null +++ b/server/app/services/simulador.py @@ -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] diff --git a/server/app/services/ws_manager.py b/server/app/services/ws_manager.py new file mode 100644 index 0000000..0286e0b --- /dev/null +++ b/server/app/services/ws_manager.py @@ -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() diff --git a/server/app/use_cases/__init__.py b/server/app/use_cases/__init__.py new file mode 100644 index 0000000..fbcaea7 --- /dev/null +++ b/server/app/use_cases/__init__.py @@ -0,0 +1,3 @@ +from app.use_cases.obtener_eta import ObtenerETAUseCase, ETAResponse + +__all__ = ["ObtenerETAUseCase", "ETAResponse"] \ No newline at end of file diff --git a/server/app/use_cases/obtener_eta.py b/server/app/use_cases/obtener_eta.py new file mode 100644 index 0000000..b6ab4ad --- /dev/null +++ b/server/app/use_cases/obtener_eta.py @@ -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, + ) \ No newline at end of file diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..4ba88d0 --- /dev/null +++ b/server/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/server/dockerfile b/server/dockerfile new file mode 100644 index 0000000..9cb2bd9 --- /dev/null +++ b/server/dockerfile @@ -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"] \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..55ff50b --- /dev/null +++ b/server/requirements.txt @@ -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 \ No newline at end of file diff --git a/server/schema_supabase.sql b/server/schema_supabase.sql new file mode 100644 index 0000000..fc81ac0 --- /dev/null +++ b/server/schema_supabase.sql @@ -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; diff --git a/server/tests/__init__.py b/server/tests/__init__.py new file mode 100644 index 0000000..e69de29