319 lines
12 KiB
Markdown
319 lines
12 KiB
Markdown
# 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 |
|