12 KiB
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 cliente–servidor 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,lngtimestamp(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/lngdel 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) yrole - 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
routes.jsoncontiene 15 rutas con 8 waypoints + timestamps cada una- Los timestamps son templates relativos: se reinterpretan cada día
eta_service.pylos proyecta a la fecha actual y calcula la posición simulada por interpolación temporal- 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:
- Lea la posición actual del camión desde una tabla
truck_positions(alimentada por GPS real) - Compare contra el waypoint del usuario
- 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étodoslogin/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
fetchnativo en lugar de axios (compatibilidad con RN nueva arq.)- Interceptor de auth:
services/api.jsañade elAuthorization: Bearerautomá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— reemplazaglobal.URLcon 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
#C8960Cpara 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 |