# 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`) ```python # 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`](../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`) ```python 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`: ```python 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: ```bash # 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 ```bash # 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 |