diff --git a/server/.env.example b/server/.env.example index 51a010f..1430453 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,5 +1,11 @@ SECRET_KEY=your-super-secret-key-change-this-in-production DEBUG=true -DATABASE_PATH=/data/basura.db + +# Supabase PostgreSQL +SUPABASE_URL=https://qckndtzudciejpnwqfzt.supabase.co +SUPABASE_ANON_KEY=sb_publishable_FQR0WXK6joM043Qve9gz3A_pJfAH... +SUPABASE_SERVICE_KEY=sb_secret_2y3a_... + +# Simulador SIM_TICK_SECONDS=10 SIM_ETA_ALERT_MINUTES=10 \ No newline at end of file diff --git a/server/app/data/repositories/ruta_repository.py b/server/app/data/repositories/ruta_repository.py index 92b68e6..077a2af 100644 --- a/server/app/data/repositories/ruta_repository.py +++ b/server/app/data/repositories/ruta_repository.py @@ -1,134 +1,138 @@ """ -Capa de Datos — SQLite con soporte de caché +Capa de Datos — Supabase PostgreSQL con caché """ -from datetime import datetime +from datetime import datetime, timedelta from typing import Optional -from app.db.database import get_connection +from app.db.database import get_db from app.domain.entities.ruta import ( Coordenada, EstadoCamion, ETAResult, NotificationPreferences, PuntoRuta, Ruta, TruckStatus, ) -class SQLiteRutaRepository: +class SupabaseRutaRepository: + + def __init__(self): + self.db = get_db() # ── 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"], + try: + ruta_data = self.db.table("rutas").select("*").eq("id", route_id).execute() + if not ruta_data.data: + return None + + ruta_row = ruta_data.data[0] + puntos_data = self.db.table("puntos_ruta").select("*").eq("ruta_id", route_id).order("orden").execute() + + puntos = [ + PuntoRuta( + orden=p["orden"], + nombre=p["nombre"], + coordenada=Coordenada(p["lat"], p["lng"]), + tiempo_estimado_min=p["tiempo_estimado_min"], + ) + for p in puntos_data.data + ] + + return Ruta( + id=ruta_row["id"], + nombre=ruta_row["nombre"], + puntos=puntos, + turno=ruta_row["turno"] ) - 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"]) + except Exception as e: + print(f"Error obtener_ruta: {e}") + return None 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: + try: + addr_data = self.db.table("addresses").select("route_id").eq("id", address_id).execute() + if not addr_data.data: + return None + return self.obtener_ruta(addr_data.data[0]["route_id"]) + except Exception as e: + print(f"Error obtener_ruta_por_address: {e}") 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: + try: + ts_data = self.db.table("truck_status").select("*").eq("route_id", route_id).execute() + if not ts_data.data: + return None + + row = ts_data.data[0] + return TruckStatus( + route_id=row["route_id"], + current_position_id=row["current_position_id"], + last_update=datetime.fromisoformat(row["last_update"].replace("Z", "+00:00")), + status=EstadoCamion(row["status"]), + ) + except Exception as e: + print(f"Error obtener_truck_status: {e}") 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)) + try: + self.db.table("truck_status").upsert({ + "route_id": ts.route_id, + "current_position_id": ts.current_position_id, + "last_update": ts.last_update.isoformat(), + "status": ts.status.value, + }).execute() + + # Invalidar caché + from app.core.cache import invalidate_route_cache + import asyncio + asyncio.create_task(invalidate_route_cache(ts.route_id)) + except Exception as e: + print(f"Error guardar_truck_status: {e}") # ── 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: + def obtener_preferencias(self, user_id: str) -> NotificationPreferences: + try: + prefs_data = self.db.table("notification_preferences").select("*").eq("user_id", user_id).execute() + if not prefs_data.data: + return NotificationPreferences(user_id=user_id) + + row = prefs_data.data[0] + return NotificationPreferences( + user_id=user_id, + notify_proximity=row["notify_proximity"], + notify_breakdown=row["notify_breakdown"], + notify_delay=row["notify_delay"], + notify_route_start=row["notify_route_start"], + ) + except Exception as e: + print(f"Error obtener_preferencias: {e}") 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] + try: + users_data = self.db.table("addresses").select("id, user_id").eq("route_id", route_id).execute() + return [{"address_id": u["id"], "user_id": u["user_id"]} for u in users_data.data] + except Exception as e: + print(f"Error obtener_usuarios_por_ruta: {e}") + return [] # ── 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 + try: + template_data = self.db.table("notification_templates").select("*").eq("trigger_event", trigger_event).execute() + if not template_data.data: + return None + return template_data.data[0] + except Exception as e: + print(f"Error obtener_template: {e}") + return None - # ── ETA calculado con soporte de caché ──────────────────────────── + # ── ETA calculado ──────────────────────────────────────────────── def calcular_eta(self, address_id: int) -> Optional[ETAResult]: ruta = self.obtener_ruta_por_address(address_id) @@ -151,7 +155,6 @@ class SQLiteRutaRepository: 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") @@ -178,12 +181,18 @@ class SQLiteRutaRepository: 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 + try: + self.db.table("notificaciones").insert({ + "tipo": tipo, + "ruta_id": route_id, + "address_id": address_id, + "mensaje": mensaje, + "eta_minutos": eta_minutos, + "creada_en": datetime.utcnow().isoformat(), + }).execute() + except Exception as e: + print(f"Error guardar_notificacion: {e}") + + +# Alias para compatibilidad +SQLiteRutaRepository = SupabaseRutaRepository diff --git a/server/app/db/database.py b/server/app/db/database.py index 616a280..291d077 100644 --- a/server/app/db/database.py +++ b/server/app/db/database.py @@ -1,142 +1,82 @@ """ -Base de datos SQLite — esquema unificado con Persona A. -Tablas propias del módulo B: truck_status, notificaciones, ws_sessions. +Base de datos Supabase PostgreSQL — conexión y utilidades. """ -import sqlite3 -from pathlib import Path +import os +from supabase import create_client, Client +from dotenv import load_dotenv -DB_PATH = Path("basura.db") +load_dotenv() + +SUPABASE_URL = os.getenv("SUPABASE_URL", "https://qckndtzudciejpnwqfzt.supabase.co") +SUPABASE_KEY = os.getenv("SUPABASE_ANON_KEY", "sb_publishable_FQR0WXK6joM043Qve9gz3A_pJfAH...") + +supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) -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 get_db() -> Client: + """Retorna cliente Supabase.""" + return supabase -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() +async def init_db() -> None: + """ + Inicializa BD. En Supabase, tablas ya existen en el schema_supabase.sql. + Esta función valida conexión y seed data si es necesario. + """ + try: + # Valida conexión leyendo rutas + result = supabase.table("rutas").select("id").eq("id", "RUTA-01").execute() + if not result.data: + # Seed data si no existe + _seed_datos_demo() + print("✓ BD Supabase inicializada") + except Exception as e: + print(f"✗ Error inicialización BD: {e}") + raise 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 + """Inserta datos demo si no existen.""" + try: + # Rutas + supabase.table("rutas").insert({ + "id": "RUTA-01", + "nombre": "Ruta 01 — Sector Centro", + "turno": "mañana" + }).execute() - conn.executescript(""" - -- Ruta de demo (Celaya, Guanajuato) - INSERT INTO rutas VALUES ('RUTA-01', 'Ruta 01 — Sector Centro', 'mañana'); + # Puntos ruta + puntos = [ + {"ruta_id": "RUTA-01", "orden": 1, "nombre": "Estación Central", "lat": 20.5238, "lng": -100.8143, "tiempo_estimado_min": 0}, + {"ruta_id": "RUTA-01", "orden": 2, "nombre": "Col. Independencia", "lat": 20.5255, "lng": -100.8090, "tiempo_estimado_min": 8}, + {"ruta_id": "RUTA-01", "orden": 3, "nombre": "Blvd. A. López Mateos", "lat": 20.5271, "lng": -100.8021, "tiempo_estimado_min": 18}, + {"ruta_id": "RUTA-01", "orden": 4, "nombre": "Col. Jardines del Bosque", "lat": 20.5290, "lng": -100.7965, "tiempo_estimado_min": 28}, + {"ruta_id": "RUTA-01", "orden": 5, "nombre": "Mercado Hidalgo", "lat": 20.5310, "lng": -100.7910, "tiempo_estimado_min": 38}, + ] + for punto in puntos: + supabase.table("puntos_ruta").insert(punto).execute() - 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); + # Truck status + supabase.table("truck_status").insert({ + "route_id": "RUTA-01", + "current_position_id": 1, + "status": "EN_RUTA" + }).execute() - INSERT INTO truck_status VALUES ('RUTA-01', 1, CURRENT_TIMESTAMP, 'EN_RUTA'); + # Notification templates + templates = [ + {"trigger_event": "ruta_iniciada", "title": "Ruta iniciada", "body": "El camión ha comenzado su ruta. Prepárate."}, + {"trigger_event": "aproximandose", "title": "¡Camión cerca!", "body": "El camión llega en ~{eta} minutos. Saca tu basura."}, + {"trigger_event": "falla_mecanica", "title": "Aviso de servicio", "body": "El camión reportó una falla. Te notificaremos cuando se reanude."}, + {"trigger_event": "ruta_tarde", "title": "Cambio de horario", "body": "El camión de la mañana pasará en el turno de la tarde."}, + {"trigger_event": "completado", "title": "Ruta completada", "body": "El camión completó su paso por tu zona. ¡Hasta mañana!"}, + ] + for template in templates: + try: + supabase.table("notification_templates").insert(template).execute() + except: + pass # Ignorar duplicados - -- 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() + print("✓ Datos demo insertados") + except Exception as e: + print(f"✗ Error seed data: {e}") diff --git a/server/requirements.txt b/server/requirements.txt index 55ff50b..d739b1d 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -12,4 +12,6 @@ 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 +hiredis==2.3.2 +supabase==2.3.4 +psycopg2-binary==2.9.9 \ No newline at end of file