diff --git a/backend/app/api/addresses.py b/backend/app/api/addresses.py index 75e142e..3b02662 100644 --- a/backend/app/api/addresses.py +++ b/backend/app/api/addresses.py @@ -96,3 +96,61 @@ def get_address( raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio") return address + + +@router.get("/{address_id}/unit") +def get_address_unit( + address_id: str, + current_user: dict = Depends(get_current_user), +): + """ + Devuelve la unidad asignada al domicilio (a través de su ruta). + Cadena: addresses.route_id → routes.truck_id → units. + El ciudadano sólo puede consultar sus propios domicilios. + """ + addr_res = ( + supabase_admin.table("addresses") + .select("id, user_id, route_id") + .eq("id", address_id) + .maybe_single() + .execute() + ) + if not addr_res.data: + raise HTTPException(status_code=404, detail="Domicilio no encontrado") + + address = addr_res.data + if current_user["role"] != "admin" and address["user_id"] != current_user["user_id"]: + raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio") + + route_id = address.get("route_id") + if not route_id: + raise HTTPException( + status_code=404, + detail="El domicilio no tiene una ruta asociada", + ) + + route_res = ( + supabase_admin.table("routes") + .select("id, truck_id") + .eq("id", route_id) + .maybe_single() + .execute() + ) + if not route_res.data or route_res.data.get("truck_id") is None: + raise HTTPException( + status_code=404, + detail="La ruta asignada no tiene unidad activa", + ) + + truck_id = route_res.data["truck_id"] + unit_res = ( + supabase_admin.table("units") + .select("id, plate, status") + .eq("id", truck_id) + .maybe_single() + .execute() + ) + if not unit_res.data: + raise HTTPException(status_code=404, detail="Unidad no encontrada") + + return unit_res.data diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index a937e96..470eea8 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -6,7 +6,7 @@ Operan directamente contra Supabase (RLS bypaseado por service_role). from typing import Optional from fastapi import APIRouter, Depends, HTTPException -from app.core.deps import require_role +from app.core.deps import get_current_user, require_role from app.core.supabase_client import supabase_admin from app.schemas.admin import ( AdminUser, @@ -375,3 +375,156 @@ def delete_driver(driver_id: str): except Exception as e: raise HTTPException(400, f"Error al borrar el conductor: {e}") return None + + +# ── Incidents por unidad ────────────────────────────────────────────────────── +_INCIDENT_CATEGORIES = { + "derrame", + "dano_propiedad", + "conducta", + "no_recoleccion", + "otro", +} + + +def _route_for_unit(unit_id: int) -> Optional[str]: + """Devuelve el route_id que actualmente tiene asignada la unidad + (vía routes.truck_id), o None si no está asignada a ninguna ruta.""" + try: + res = ( + supabase_admin.table("routes") + .select("id") + .eq("truck_id", unit_id) + .limit(1) + .execute() + ) + rows = res.data or [] + return rows[0]["id"] if rows else None + except Exception: + return None + + +def _driver_for_unit(unit_id: int, users_map: dict[str, dict]) -> Optional[str]: + """Devuelve el nombre del chofer asignado a la unidad (drivers.unit_id).""" + try: + res = ( + supabase_admin.table("drivers") + .select("user_id") + .eq("unit_id", unit_id) + .limit(1) + .execute() + ) + rows = res.data or [] + if not rows: + return None + user_id = str(rows[0].get("user_id")) + return (users_map.get(user_id) or {}).get("name") + except Exception: + return None + + +def _serialize_incident(row: dict, route_id: Optional[str], driver_name: Optional[str]) -> dict: + """Da la forma que espera el cliente Flutter (admin_incident.dart).""" + return { + "id": str(row.get("id")), + "unit_id": row.get("unit_id"), + "route_id": route_id, + "type": row.get("category"), + "description": row.get("description"), + "driver_name": driver_name, + "status": row.get("status") or "open", + "photo_url": row.get("photo_url"), + "created_at": row.get("created_at") and str(row["created_at"]), + } + + +@router.get("/units/{unit_id}/incidents") +def list_unit_incidents(unit_id: int): + """Reportes ciudadanos asociados a esta unidad, más reciente primero.""" + # Verifica que la unidad exista (devuelve 404 si no) + unit_res = ( + supabase_admin.table("units") + .select("id") + .eq("id", unit_id) + .maybe_single() + .execute() + ) + if not unit_res.data: + raise HTTPException(404, "Unidad no encontrada") + + try: + res = ( + supabase_admin.table("incidents") + .select("id, unit_id, user_id, category, description, status, photo_url, created_at") + .eq("unit_id", unit_id) + .order("created_at", desc=True) + .execute() + ) + except Exception as e: + raise HTTPException(500, f"Error al listar incidencias: {e}") + + rows = res.data or [] + route_id = _route_for_unit(unit_id) + + # Pre-cargar el mapa de nombres una sola vez + users_res = supabase_admin.table("users").select("id, name").execute() + users_map: dict[str, dict] = { + str(u["id"]): {"name": u.get("name")} for u in (users_res.data or []) + } + driver_name = _driver_for_unit(unit_id, users_map) + + return [_serialize_incident(r, route_id, driver_name) for r in rows] + + +@router.post("/units/{unit_id}/incidents", status_code=201) +def create_unit_incident( + unit_id: int, + body: dict, + current_user: dict = Depends(get_current_user), +): + """Crea una incidencia para esta unidad. El admin queda como reporter.""" + category = (body.get("type") or body.get("category") or "").strip() + description = (body.get("description") or "").strip() + + if category not in _INCIDENT_CATEGORIES: + raise HTTPException( + 400, + f"Categoría inválida. Use una de: {sorted(_INCIDENT_CATEGORIES)}", + ) + if len(description) < 3: + # La tabla requiere description NOT NULL y un mínimo razonable. + description = description or category + + # La unidad debe existir + unit_res = ( + supabase_admin.table("units") + .select("id") + .eq("id", unit_id) + .maybe_single() + .execute() + ) + if not unit_res.data: + raise HTTPException(404, "Unidad no encontrada") + + payload = { + "user_id": current_user["user_id"], + "unit_id": unit_id, + "category": category, + "description": description, + } + try: + res = supabase_admin.table("incidents").insert(payload).execute() + except Exception as e: + raise HTTPException(500, f"Error al crear la incidencia: {e}") + + row = (res.data or [None])[0] + if not row: + raise HTTPException(500, "Supabase no devolvió la fila creada") + + route_id = _route_for_unit(unit_id) + users_res = supabase_admin.table("users").select("id, name").execute() + users_map: dict[str, dict] = { + str(u["id"]): {"name": u.get("name")} for u in (users_res.data or []) + } + driver_name = _driver_for_unit(unit_id, users_map) + return _serialize_incident(row, route_id, driver_name) diff --git a/backend/app/api/feedback.py b/backend/app/api/feedback.py new file mode 100644 index 0000000..4da2fe2 --- /dev/null +++ b/backend/app/api/feedback.py @@ -0,0 +1,93 @@ +"""Endpoint de retroalimentación ciudadana. + +Reglas: +- El user_id se toma del token (nunca del body). +- target_unit_id es la UNIDAD (camión), no el chofer. +- La inserción usa supabase_admin (service_role) que bypassea RLS. +""" +from fastapi import APIRouter, Depends, HTTPException + +from app.core.deps import get_current_user +from app.core.supabase_client import supabase_admin +from app.schemas.feedback import FeedbackCreate, FeedbackOut + +router = APIRouter(prefix="/feedback", tags=["feedback"]) + + +@router.post("", response_model=FeedbackOut, status_code=201) +def create_feedback( + body: FeedbackCreate, + current_user: dict = Depends(get_current_user), +): + user_id = current_user["user_id"] + + # Validar que la unidad exista si se envió + if body.target_unit_id is not None: + try: + unit = ( + supabase_admin.table("units") + .select("id") + .eq("id", body.target_unit_id) + .limit(1) + .execute() + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error al validar unidad: {e}") + if not unit.data: + raise HTTPException(status_code=400, detail="Unidad inexistente") + + payload = { + "user_id": user_id, + "address_id": body.address_id, + "type": body.type, + "target_unit_id": body.target_unit_id, + "message": body.message, + "rating": body.rating, + } + + try: + res = supabase_admin.table("feedback").insert(payload).execute() + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error al guardar feedback: {e}") + + row = (res.data or [{}])[0] + return FeedbackOut( + id=str(row.get("id")), + user_id=str(row.get("user_id")), + address_id=row.get("address_id") and str(row["address_id"]), + type=row.get("type") or body.type, + target_unit_id=row.get("target_unit_id"), + message=row.get("message"), + rating=row.get("rating"), + created_at=row.get("created_at") and str(row["created_at"]), + ) + + +@router.get("/me", response_model=list[FeedbackOut]) +def my_feedback(current_user: dict = Depends(get_current_user)): + try: + res = ( + supabase_admin.table("feedback") + .select("*") + .eq("user_id", current_user["user_id"]) + .order("created_at", desc=True) + .execute() + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error al listar feedback: {e}") + + out: list[FeedbackOut] = [] + for row in res.data or []: + out.append( + FeedbackOut( + id=str(row.get("id")), + user_id=str(row.get("user_id")), + address_id=row.get("address_id") and str(row["address_id"]), + type=row.get("type") or "", + target_unit_id=row.get("target_unit_id"), + message=row.get("message"), + rating=row.get("rating"), + created_at=row.get("created_at") and str(row["created_at"]), + ) + ) + return out diff --git a/backend/app/schemas/feedback.py b/backend/app/schemas/feedback.py new file mode 100644 index 0000000..9342fb4 --- /dev/null +++ b/backend/app/schemas/feedback.py @@ -0,0 +1,28 @@ +"""Esquemas Pydantic para retroalimentación ciudadana.""" +from typing import Optional +from pydantic import BaseModel, Field + + +class FeedbackCreate(BaseModel): + """Cuerpo enviado por el ciudadano al crear una queja/sugerencia. + + Importante: NUNCA se acepta driver_id; solo el número de la unidad. + """ + + address_id: Optional[str] = None # UUID + type: str = Field(min_length=1, max_length=40) + rating: int = Field(ge=1, le=5) + # Llega como string ("101") desde Flutter; Pydantic lo coacciona a int. + target_unit_id: Optional[int] = None + message: Optional[str] = Field(default=None, max_length=1000) + + +class FeedbackOut(BaseModel): + id: str + user_id: str + address_id: Optional[str] = None + type: str + target_unit_id: Optional[int] = None + message: Optional[str] = None + rating: Optional[int] = None + created_at: Optional[str] = None diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py index 193b073..01fbdab 100644 --- a/backend/app/services/notifications.py +++ b/backend/app/services/notifications.py @@ -1,5 +1,7 @@ import os import firebase_admin +import urllib.parse +import urllib.request from firebase_admin import credentials, messaging _firebase_initialized = False @@ -27,9 +29,13 @@ def send_to_topic(topic: str, payload: dict): """Envía una notificación push a todos los dispositivos suscritos a un topic (ej. RUTA-01).""" title = payload.get("title", "") body = payload.get("body", "") + # `data` viaja como Dict[str, str] en FCM; permite que el cliente + # clasifique el evento (event, routeId) en notifications_screen.dart. + raw_data = payload.get("data") or {} + data: dict[str, str] = {str(k): str(v) for k, v in raw_data.items() if v is not None} if not _firebase_initialized: - print(f"[MOCK PUSH] -> Topic: {topic} | Título: '{title}' | Mensaje: '{body}'") + print(f"[MOCK PUSH] -> Topic: {topic} | Título: '{title}' | Mensaje: '{body}' | Data: {data}") return try: @@ -38,9 +44,29 @@ def send_to_topic(topic: str, payload: dict): title=title, body=body, ), + data=data or None, topic=topic, ) response = messaging.send(message) print(f"Push enviado al topic '{topic}' exitosamente. MessageID: {response}") except Exception as e: - print(f"Error al enviar push al topic '{topic}': {e}") \ No newline at end of file + print(f"Error al enviar push al topic '{topic}': {e}") + +def send_whatsapp_alert(phone: str, message: str): + """ + Envía un WhatsApp usando la API gratuita de CallMeBot. + """ + print("\n" + "="*50) + print(f"🟢 [WHATSAPP TRIGGER] Preparando mensaje para {phone}") + print(f"💬 Mensaje: {message}") + print("="*50 + "\n") + + # REEMPLAZA ESTO POR EL CÓDIGO QUE TE DIO EL BOT EN WHATSAPP: + apikey = "6756808" # <--- BORRA "TU_API_KEY_AQUI" Y PON TU CÓDIGO REAL AQUÍ + safe_msg = urllib.parse.quote(message) + url = f"https://api.callmebot.com/whatsapp.php?phone={phone}&text={safe_msg}&apikey={apikey}" + + try: + urllib.request.urlopen(url, timeout=5) + except Exception as e: + print(f"Error enviando WhatsApp real: {e}") \ No newline at end of file diff --git a/backend/app/services/simulation.py b/backend/app/services/simulation.py index 72d6299..9a3784b 100644 --- a/backend/app/services/simulation.py +++ b/backend/app/services/simulation.py @@ -1,5 +1,6 @@ import json import os +import time from typing import Dict, List, Optional from app.services import notifications @@ -58,34 +59,65 @@ def _find_notif(event_name: str) -> Optional[Dict]: def tick() -> List[Dict]: - """Avanza todas las rutas en memoria (pos 1..8) y devuelve eventos disparados.""" - global ESTADO, LAST_EVENTS + """Avanza todas las rutas en memoria (pos 1→8) y devuelve eventos disparados. + Al llegar a pos 8, reinicia la ruta para que la demo sea un ciclo continuo.""" + global ESTADO, STATUS, LAST_EVENTS events = [] for route_id, pos in list(ESTADO.items()): - if pos < 8: - antes = pos - ahora = pos + 1 - ESTADO[route_id] = ahora - evt = None - if antes == 1 and ahora == 2: - evt = "ROUTE_START" - elif ahora == 4: - evt = "TRUCK_PROXIMITY" - elif ahora == 8: - evt = "ROUTE_COMPLETED" + # Ciclo: cuando la ruta completó, reiniciar en el siguiente tick + if pos >= 8: + ESTADO[route_id] = 1 + STATUS[route_id] = "PENDIENTE" + print(f"[SIM RESET] {route_id} reiniciado para nuevo ciclo.") + continue - if evt: - notif = _find_notif(evt) - payload = notif.get("pushPayload") if notif else {"title": evt, "body": ""} - simulated = {"routeId": route_id, "event": evt, "payload": payload} - events.append(simulated) - LAST_EVENTS.append(simulated) - # Enviar push vía servicio de notificaciones (FCM) o mock - topic = f"topic_{route_id}" - try: - notifications.send_to_topic(topic, payload) - except Exception: - print(f"[SIM PUSH FAIL] {route_id} -> {evt}: {payload.get('title')} - {payload.get('body')}") + antes = pos + ahora = pos + 1 + ESTADO[route_id] = ahora + + # Actualizar STATUS según la nueva posición + if ahora == 2: + STATUS[route_id] = "en_ruta" + elif ahora == 8: + STATUS[route_id] = "completada" + + evt = None + if antes == 1 and ahora == 2: + evt = "ROUTE_START" + elif ahora == 4: + evt = "TRUCK_PROXIMITY" + elif ahora == 8: + evt = "ROUTE_COMPLETED" + + if evt: + notif = _find_notif(evt) + payload = notif.get("pushPayload") if notif else {"title": evt, "body": ""} + # Adjuntar event + routeId en `data` para que el cliente Flutter + # los clasifique (notifications_screen.dart usa msg.data['event']). + payload = { + **payload, + "data": { + **(payload.get("data") or {}), + "event": evt, + "routeId": route_id, + }, + } + simulated = {"routeId": route_id, "event": evt, "payload": payload} + events.append(simulated) + LAST_EVENTS.append(simulated) + topic = f"topic_{route_id}" + try: + notifications.send_to_topic(topic, payload) + + # ── WHATSAPP: Se dispara cuando el camión está cerca (posición 4) ── + if evt == "TRUCK_PROXIMITY": + # Para evitar saturar el bot y bloquear el servidor, + # enviamos el WhatsApp de demostración solo para la primera ruta. + if route_id == "RUTA-01": + msg = f"¡Hola! Soy Eco 🍃. El camión recolector de tu ruta ({route_id}) está a menos de 15 minutos. ¡Saca la basura! ♻️" + notifications.send_whatsapp_alert("5214131060699", msg) + except Exception: + print(f"[SIM PUSH FAIL] {route_id} -> {evt}: {payload.get('title')} - {payload.get('body')}") return events diff --git a/backend/main.py b/backend/main.py index 316643d..5bdda04 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,6 +13,7 @@ from app.api.admin import router as admin_router from app.api.simulation import router as simulation_router from app.api.chat import router as chat_router from app.api.incidents import router as incidents_router +from app.api.feedback import router as feedback_router from app.services import simulation, notifications scheduler = AsyncIOScheduler() @@ -67,3 +68,4 @@ app.include_router(admin_router) app.include_router(simulation_router) app.include_router(chat_router) app.include_router(incidents_router) +app.include_router(feedback_router) diff --git a/recolecta_app/android/app/src/main/AndroidManifest.xml b/recolecta_app/android/app/src/main/AndroidManifest.xml index d7bb893..717e6ec 100644 --- a/recolecta_app/android/app/src/main/AndroidManifest.xml +++ b/recolecta_app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + + + + + +