Files
hackathon-v-escape-4ff8b5a6…/docs/ARQUITECTURA.md
marianesaldana 80dbd947e5 Initial commit
2026-05-23 08:59:34 -06:00

12 KiB
Raw Blame History

Arquitectura técnica

Documento técnico para evaluación. Decisiones de diseño, algoritmos clave y consideraciones de privacidad/seguridad.


1. Visión general

Mi Ruta Limpia es una arquitectura clienteservidor REST con simulación de eventos en el backend para emular telemetría de la flotilla, sin requerir GPS real.

[App móvil RN] ↔ HTTPS/REST + JWT ↔ [FastAPI] ↔ SQLite + routes.json (mock)

Por qué esta arquitectura

Decisión Razón
FastAPI (Python) Validación automática con Pydantic, OpenAPI docs gratis, async-ready, productivo en hackathon
SQLite (no PostgreSQL) Zero-config, suficiente para hackathon, migración a PG es trivial (cambiar DATABASE_URL)
JSON mock para rutas Cumple el requisito de "simular eventos sin telemetría real" sin complejidad innecesaria
JWT stateless No requiere sesión en servidor, escalable horizontalmente
React Native + Expo Multiplataforma (iOS, Android, web) con un solo código
fetch sobre axios Axios rompe en RN nueva arquitectura por URL.protocol strict mode

2. Backend — Estructura por capas

app/
├── config.py        ← Settings con pydantic-settings (env vars)
├── database.py      ← SQLAlchemy engine + sessionmaker
├── models/          ← ORM (capa de datos)
├── schemas/         ← Pydantic (capa de validación / serialización)
├── routers/         ← Endpoints HTTP (capa de presentación)
└── services/        ← Lógica de negocio (capa de dominio)

Patrón: los routers solo orquestan. Toda la lógica real vive en services/. Los modelos no contienen lógica.

Inyección de dependencias (FastAPI Depends)

# Cada endpoint declara qué necesita:
@router.get("/eta/address/{id}")
def get_eta(
    id: int,
    db: Session = Depends(get_db),        BD sesión
    user = Depends(get_current_user),      JWT validado
):
    ...

Esto permite testear cada endpoint con mocks fácilmente.


3. Modelo de datos

users (id, email, phone, full_name, hashed_password, role, oauth_*, push_token, is_active)
  │
  ├──< addresses (id, user_id, label, street, colony, lat, lng, route_id, is_default)
  │       │
  │       ├──< reports (id, user_id, address_id, folio, report_type, description, status, created_at)
  │       │
  │       └──< service_ratings (id, user_id, address_id, rating, comment, created_at)
  │
  └──< operational_reports (id, employee_id, folio, category, severity, route_id, truck_id, status, ...)

trucks (id, unit_number, plate, model, year, status, route_id, base, odometer_km, fuel_level_pct, ...)

Roles (RBAC)

  • CIUDADANO — default. Solo accede a /auth, /addresses (suyos), /eta (suyos), /reports (suyos)
  • EMPLEADO — accede a /staff/* (su propio dashboard, sus reportes operativos). NO accede a /admin/*
  • ADMIN — accede a todo, incluyendo /admin/* (gestión de reportes ciudadanos y usuarios)

Migraciones

Por simplicidad de hackathon, las migraciones se hacen con Base.metadata.create_all() + ALTER TABLE inline en seed.py. En producción se debe usar Alembic.


4. Algoritmo ETA — núcleo del sistema

Problema

Dado un domicilio (lat, lng) y la hora actual, calcular cuándo pasará el camión sin exponer su ubicación real.

Datos de entrada

backend/app/data/routes.json — 15 rutas, cada una con 8 waypoints, cada waypoint con:

  • lat, lng
  • timestamp (template, se reinterpreta cada día)
  • speed

Algoritmo (app/services/eta_service.py)

def get_eta(route_id, user_lat, user_lng):
    route = load_route(route_id)
    now = datetime.now(CELAYA_TZ)
    
    # 1. Construir horario del día con base en los timestamps templates
    first_t = today_at(parse(route.positions[0].timestamp))
    last_t  = today_at(parse(route.positions[-1].timestamp))
    
    # 2. Determinar fase
    if now < first_t:   return PROGRAMADO    # no ha empezado
    if now > last_t:    return PASO          # ya terminó
    
    # 3. Encontrar entre qué dos waypoints está el camión ahora
    current_idx = find_segment(route, now)
    
    # 4. Encontrar el waypoint más cercano al domicilio del usuario
    closest_idx = argmin(haversine(user, w) for w in route.positions)
    
    # 5. Comparar
    if current_idx > closest_idx:   return PASO         # ya pasó
    if current_idx == closest_idx:  return LLEGANDO     # muy cerca
    
    # 6. Calcular ventana horaria
    user_time = today_at(parse(route.positions[closest_idx].timestamp))
    eta_minutes = (user_time - now).minutes
    
    return {
        'status': 'EN_CAMINO',
        'message': f'Entre las {user_time - 8min} y {user_time + 8min}',
        'eta_minutes': eta_minutes,
        'progress': elapsed_pct(now, first_t, last_t),
    }

Por qué este enfoque

  • No requiere telemetría real: los timestamps son el "schedule" diario; la posición actual se infiere de la hora
  • Privacy by design built-in: la API jamás devuelve lat/lng del camión
  • Predecible: el ciudadano sabe el rango horario, no un punto exacto
  • Tolerante a tráfico: las ventanas (±8 minutos) absorben la variabilidad

Geo-routing automático

Cuando un ciudadano registra un domicilio sin route_id, assign_route(lat, lng) calcula el waypoint más cercano de todas las rutas y asigna la ruta correspondiente (algoritmo: distancia Haversine en O(n) con n=15·8=120 waypoints).


5. Seguridad

Autenticación

  • bcrypt con cost factor 12 para hashing
  • JWT firmado con HS256, expira en 7 días
  • El token incluye sub (user_id) y role
  • Los endpoints sensibles validan el token con HTTPBearer + dependency

Autorización (RBAC)

Tres dependencies en routers/deps.py:

get_current_user  cualquier rol autenticado
require_staff     EMPLEADO o ADMIN (devuelve 403 si no)
require_admin     solo ADMIN (devuelve 403 si no)

Probado con curl:

# Ciudadano → /admin/stats → HTTP 403 ✓
# Empleado  → /admin/reports → HTTP 403 ✓
# Empleado  → /staff/dashboard → HTTP 200 ✓

Pasos adicionales para producción

  • Refresh tokens
  • Rate limiting (slowapi)
  • HTTPS obligatorio
  • CORS estricto (no *)
  • Logs auditables (sin PII)
  • Rotación de SECRET_KEY
  • 2FA opcional

6. Privacidad por Diseño

Requisito hackathon Cómo se cumple
"PROHIBIDO el seguidor de ruta activo" El endpoint /eta/address/:id nunca devuelve lat/lng del camión. Solo: status, mensaje, ventana horaria, progreso%
"PROHIBIDO el snooping" El backend valida Address.user_id == current_user.id en cada petición. Si intentas consultar /eta/address/999 y no te pertenece → 404
"Visión de túnel: solo tu ruta" El cliente solo recibe route_name (texto), nunca el polyline de la ruta
"Mensajería preventiva" Banner explícito en HomeScreen: "Por tu seguridad, no persigas al camión. ¡Saca tu basura con tiempo!"

Verificación

# Login como demo (id=1)
curl -X POST .../auth/login -d '{"email":"demo@celaya.gob.mx","password":"Celaya2026"}'

# Intentar acceder a un domicilio ajeno (id=999)
curl -H "Authorization: Bearer $TOKEN" .../eta/address/999
# → HTTP 404 "Domicilio no encontrado"

7. Simulación de eventos

Cumple el requisito "Dado que no tendrán acceso a la telemetría real, deberán simular el avance de la ruta".

Estrategia

  1. routes.json contiene 15 rutas con 8 waypoints + timestamps cada una
  2. Los timestamps son templates relativos: se reinterpretan cada día
  3. eta_service.py los proyecta a la fecha actual y calcula la posición simulada por interpolación temporal
  4. No hay cron job ni websockets — la simulación es stateless y on-demand

Ventaja

Cualquier petición al backend, en cualquier momento del día, devuelve un ETA coherente con la "hora del camión" sin necesidad de procesos en background.

Migración a telemetría real

Para producción con GPS real, solo cambiar eta_service.get_eta() para que:

  1. Lea la posición actual del camión desde una tabla truck_positions (alimentada por GPS real)
  2. Compare contra el waypoint del usuario
  3. Devuelva la misma estructura de respuesta — el cliente no cambia

8. Frontend — Arquitectura

Routing por rol

LoginScreen
   ↓ (login exitoso)
AuthContext.user.role
   ├── 'CIUDADANO' → CitizenTabs (5 pestañas)
   ├── 'EMPLEADO'  → EmployeeTabs (3 pestañas)
   └── 'ADMIN'     → AdminTabs (4 pestañas)

Estado

  • AuthContext global (React Context) — guarda user + métodos login/logout/register
  • AsyncStorage persistente — guarda el JWT entre sesiones
  • Estado local por pantalla (useState) — listas, modales, formularios
  • No usamos Redux/Zustand — overkill para este alcance

Networking

  • fetch nativo en lugar de axios (compatibilidad con RN nueva arq.)
  • Interceptor de auth: services/api.js añade el Authorization: Bearer automáticamente y limpia el token en 401
  • No hay polling agresivo: el cliente solo refresca al pull-to-refresh o navegar

Polyfills críticos

  • react-native-url-polyfill/auto — reemplaza global.URL con la implementación WHATWG completa antes de que Expo cargue. Solución al bug "URL.protocol is not implemented" en RN 0.74

9. Decisiones de UX

Tipografía y color

  • Color guinda institucional #7B1A2E (extraído del logo del Gobierno de Celaya)
  • Acento dorado #C8960C para etiquetas de sección, evocando el slogan "ES GRANDE POR TI"
  • Encabezado guinda detrás de la dynamic island en todas las pestañas — efecto de marca premium
  • Tipografía: pesos 800-900 para títulos, 600 para subtítulos, 400 para cuerpo

Patrón de pantalla repetido

Todas las pantallas post-login siguen:

AppHeader (guinda + "Mi Ruta Limpia")
↓
Hero (gradient o imagen con título grande)
↓
[Etiqueta dorada UPPERCASE] + [Título grande sección]
↓
Cards / contenido

Microinteracciones

  • Pull-to-refresh en listas
  • Confirmación "ya pasó" con opción deshacer (persistente por día/domicilio en AsyncStorage)
  • Modales bottom-sheet para formularios largos
  • Toast/Alert nativo para feedback

10. Limitaciones conocidas

Limitación Por qué Mitigación
Sin push notifications reales Requiere Firebase Cloud Messaging y certificados El esquema push_token en User está listo; solo falta integrar FCM
Sin OAuth real Requiere SDK de Google/Facebook/Apple El endpoint /auth/oauth está implementado; solo falta integrar el SDK del lado del cliente
Sin PDF de reportes Requiere expo-print o servicio externo Implementamos compartir como texto formateado con Share nativo
BD no escala a millones SQLite no es para producción Cambiar DATABASE_URL a PostgreSQL — toda la app sigue funcionando
Simulación, no GPS real Cumple el requisito del hackathon Endpoint listo para reemplazar con telemetría real

11. Sugerencias del brief que SÍ cumplimos

Sugerencia del reto Estado
Consumo inteligente de APIs geográficas (geocodificación solo en registro, cálculos internos)
Cálculo interno de distancias (Haversine en Python)
Notificaciones asíncronas listas para FCM (esquema preparado)
Arquitectura por capas (lógica/datos separados)
API REST limpia
Multiplataforma (iOS, Android, Web)
Manejo eficiente de estado (sin polling, refresh manual)
Indexación geoespacial básica (assign_route por proximidad)
Autenticación JWT
RBAC (3 niveles)

12. Métricas del proyecto

Métrica Valor
Archivos backend 27
Archivos frontend (src/) 22
Líneas de código backend ~1,800
Líneas de código frontend ~3,500
Endpoints REST 28
Modelos de datos 6
Pantallas 14 (5 ciudadano + 3 empleado + 4 admin + 2 auth)
Componentes reusables 2 (AppHeader, GoogleGIcon)
Datos en BD (seed_massive) 202 usuarios · 302 domicilios · 280 reportes · 150 op-reportes · 62 camiones · 220 calificaciones